-
Notifications
You must be signed in to change notification settings - Fork 37
Architecture
Logitune is a Qt 6 / QML application that communicates with Logitech HID++ 2.0 devices through the Linux hidraw subsystem. This page documents the system design, signal flow, protocol layer, and key architectural decisions.
At a glance, one button press on the mouse turns into one row update in the QML UI. Each layer has one job, and AppRoot is a thin composition root that wires them together (not a place where behavior lives):
Source:
docs/wiki/diagrams/system-overview.svg. Re-render withrsvg-convert -w 1600 -h 1240 docs/wiki/diagrams/system-overview.svg -o docs/wiki/diagrams/system-overview.pngafter edits.
The app-lib band contains AppRoot plus four focused services (ActiveDeviceResolver, DeviceCommandHandler, ButtonActionDispatcher, ProfileOrchestrator) that sit between the QML UI / ViewModels and the core library's engines + HID++ stack. Each layer below has its own detailed diagram elsewhere on this page:
| Layer | Detail |
|---|---|
| Core β HID++ stack | HID++ protocol stack, feature discovery, command processor, async matching |
| Core β Desktop integration | Interface hierarchy, KDE focus tracking |
| Core β Device lifecycle | PhysicalDevice transport aggregation, Discovery flow, Disconnect and reconnect |
| App β Services | Services |
| App β Models | MVVM pattern, Model roles, Model registration |
| App β Composition root | AppRoot wiring |
| Cross-cutting flow | Window focus β profile switch β hardware commands |
The project is split into two static libraries:
| Library | Contents | Dependencies |
|---|---|---|
logitune-core |
DeviceManager, PhysicalDevice, DeviceSession, HID++ protocol + capability dispatch, ProfileEngine, ActionExecutor, ActionPresetRegistry, DeviceRegistry, JsonDevice, DescriptorWriter, LinuxDesktopBase + KDE/GNOME/Generic implementations (each with variantKey + resolveNamedAction), input injection, logging |
Qt6::Core, Qt6::DBus, libudev |
logitune-app-lib |
AppRoot, EditorModel, models (DeviceModel, ButtonModel, ActionModel, ActionFilterModel, ProfileModel, SettingsModel), TrayManager, QML module, dialogs |
logitune-core, Qt6::Quick, Qt6::Widgets |
This split allows tests to link against logitune-core and logitune-app-lib without pulling in the executable's main().
This is the central flow of the application. When the user switches to a different window, the active profile changes and hardware settings are updated.
sequenceDiagram
participant KWin as KWin Script
participant KDE as KDeDesktop
participant AR as AppRoot
participant PO as ProfileOrchestrator
participant DS as ActiveDeviceResolver
participant PE as ProfileEngine
participant DM as DeviceModel
participant DSess as DeviceSession
participant CQ as CommandProcessor
participant FD as FeatureDispatcher
participant TR as Transport
participant PM as ProfileModel
participant BM as ButtonModel
KWin->>KDE: callDBus focusChanged(resourceClass, title, desktopFileName)
KDE->>KDE: resolveDesktopFile(resourceClass)
KDE->>AR: activeWindowChanged(appId, title)
Note over AR: wireSignals routes the signal to ProfileOrchestrator
AR->>PO: onWindowFocusChanged(wmClass, title)
Note over PO: Skip shell components (plasmashell, krunner, gnome-shell)
PO->>DM: setActiveWmClass(wmClass)
PO->>DS: activeSerial()
DS-->>PO: serial
PO->>PE: profileForApp(serial, wmClass)
PE-->>PO: profileName (or "default")
Note over PO: Skip if profileName matches hardware profile
PO->>PE: cachedProfile(serial, profileName)
PO->>PE: setHardwareProfile(serial, profileName)
PO->>PO: applyProfileToHardware(profile)
PO->>DS: activeSession()
DS-->>PO: DeviceSession* (active transport)
Note over PO: applyProfileToHardware calls the session directly. DeviceCommandHandler is only used for user UI changes.
par Apply all settings on the selected session
PO->>DSess: setDPI(value)
DSess->>CQ: enqueue(AdjustableDPI, setSensorDpi, params)
and
PO->>DSess: setSmartShift(enabled, threshold)
DSess->>CQ: enqueue(SmartShift, setRatchetControl, params)
and
PO->>DSess: setScrollConfig(hiRes, invert)
DSess->>CQ: enqueue(HiResWheel, setWheelMode, params)
and
PO->>DSess: setThumbWheelMode(mode, invert)
DSess->>CQ: enqueue(ThumbWheel, setThumbwheelReporting, params)
and
PO->>DSess: divertButton(CID, divert, rawXY) (per button)
DSess->>CQ: enqueue(ReprogControlsV4, setCidReporting, params)
end
loop For each enqueued command (FIFO, paced)
CQ->>FD: callAsync(feature, functionId, params)
FD->>TR: sendRequestAsync(report)
TR->>TR: write to hidraw fd
Note over CQ: Wait 10ms before next command
end
PO-->>AR: profileApplied(serial) (signal)
Note over AR: Wired to ButtonActionDispatcher.onProfileApplied (resets thumbAccum)
PO->>PM: setHwActiveByProfileName(profileName)
Note over BM: ButtonModel is refreshed via onDisplayProfileChanged when the display profile tracks the applied hardware profile.
The ProfileEngine maintains two independent profile pointers:
- displayProfile β the profile the user is currently viewing/editing in the UI
- hardwareProfile β the profile currently applied to the device hardware
These can differ. When the user clicks a profile tab, only the display profile changes (UI updates, no hardware writes). When the focused window changes, the hardware profile changes (hardware writes, and if the user was viewing a different tab, the UI stays on that tab).
This prevents accidental hardware writes when the user is just browsing profiles.
graph TB
subgraph "Application"
DevMgr[DeviceManager]
end
subgraph "Protocol"
CQ[CommandProcessor<br/>10ms pacing, retry]
FD[FeatureDispatcher<br/>Feature table, callAsync, softwareId]
TR[Transport<br/>send/receive, timeout, error handling]
HR[HidrawDevice<br/>open, read, write, poll]
end
subgraph "Kernel"
hidraw[hidraw fd]
end
DevMgr --> CQ
DevMgr --> FD
CQ --> FD
FD --> TR
TR --> HR
HR --> hidraw
On device connect, FeatureDispatcher::enumerate() queries the Root feature (0x0000) to build a feature index table:
sequenceDiagram
participant FD as FeatureDispatcher
participant TR as Transport
participant Dev as Device
loop For each known FeatureId
FD->>TR: send Root.getFeatureID(featureId)
TR->>Dev: HID++ report
Dev-->>TR: response with featureIndex
TR-->>FD: Report
Note over FD: Store featureId -> featureIndex mapping
end
The feature table maps FeatureId enums to device-assigned 8-bit indices. For example, FeatureId::AdjustableDPI (0x2201) might map to index 0x07 on one device and 0x09 on another. All subsequent calls use the resolved index.
Known features (from HidppTypes.h):
| Feature | ID | Description |
|---|---|---|
| Root | 0x0000 |
Feature discovery |
| FeatureSet | 0x0001 |
List all features |
| DeviceName | 0x0005 |
Read device name string |
| BatteryStatus | 0x1000 |
Battery level (legacy format, MX Master 2S and older) |
| BatteryUnified | 0x1004 |
Battery level and charging status (MX Master 3S+) |
| ChangeHost | 0x1814 |
Easy-Switch host info |
| ReprogControlsV4 | 0x1b04 |
Button diversion and remapping |
| SmartShift | 0x2110 |
SmartShift V1 ratchet/freespin control |
| SmartShiftEnhanced | 0x2111 |
SmartShift V2 (MX Master 4, different function IDs) |
| HiResWheel | 0x2121 |
Scroll wheel mode and ratchet |
| ThumbWheel | 0x2150 |
Thumb wheel diversion and direction |
| AdjustableDPI | 0x2201 |
DPI range and current value |
| GestureV2 | 0x6501 |
Gesture engine (reserved) |
Features with multiple variants (Battery, SmartShift) are resolved at enumeration time via capability dispatch tables in src/core/hidpp/capabilities/. DeviceManager stores the resolved variant and uses it everywhere, so adding new variants requires only a table entry with zero DeviceManager changes.
The transport layer is the thin stack that turns "I want to call AdjustableDPI.setSensorDpi with these parameters" into bytes on an /dev/hidraw* file descriptor and back. Three types collaborate.
hidpp::HidrawDevice (src/core/hidpp/HidrawDevice.{h,cpp}) is a non-copyable RAII wrapper over the hidraw fd. It holds the device path, opens the fd with O_RDWR | O_NONBLOCK, reads HIDIOCGRAWINFO into a DeviceInfo (vendor, product, path), and exposes writeReport(span<const uint8_t>) plus readReport(timeoutMs) which uses poll() internally. Everything above this class treats the fd as an implementation detail.
hidpp::Transport (src/core/hidpp/Transport.{h,cpp}) is the blocking request / async send layer over HidrawDevice. sendRequest(Report, timeoutMs) writes the report and waits for a matching response, handling timeouts and retries via trySend (which decrements retriesLeft). sendRequestAsync(Report) writes and returns without waiting, for use by CommandProcessor + FeatureDispatcher::callAsync. Error notifications come out on deviceError(ErrorCode, featureIndex) and deviceDisconnected(). Response matching against softwareId happens one layer up in FeatureDispatcher.
ITransport (src/core/interfaces/ITransport.h) is the abstract interface used by the tests. It declares sendRequest, notificationFd, readRawReport, plus the notificationReceived / deviceDisconnected signals. Production code uses hidpp::Transport; tests substitute MockTransport to feed canned reports without a real hidraw fd.
Error paths surface on deviceError: timeouts bubble up as a null optional return from sendRequest; HID++ HwError 0x04 (device busy) is the error code that motivates the 10 ms inter-command delay in CommandProcessor.
The MX Master generations do not implement the same feature the same way: Battery is 0x1000 on MX Master 2S and older, 0x1004 on 3S and newer; SmartShift is 0x2110 on 3S but 0x2111 (Enhanced) on MX Master 4; ReprogControls has five versions (V1 through V4) with different function tables. Rather than scatter per-variant conditionals through DeviceSession, the codebase uses a capability dispatch table pattern under src/core/hidpp/capabilities/.
Each capability is a plain struct that stores the FeatureId it matches, the function IDs it uses for get / set, and function pointers for report parsing or request building:
-
BatteryVariant(BatteryCapability.h):feature,getFn,parse(Report&) -> BatteryStatus. Two entries inkBatteryVariants:BatteryUnified (0x1004)preferred,BatteryStatus (0x1000)fallback. -
SmartShiftVariant(SmartShiftCapability.h):feature,getFn,setFn,parseGet,buildSet(mode, autoDisengage). Two entries inkSmartShiftVariants:SmartShift (0x2110)for 3S and older,SmartShiftEnhanced (0x2111)for 4 and newer. -
ReprogControlsVariant(ReprogControlsCapability.h):feature,supportsDiversion(only V4). Five entries inkReprogControlsVariantsspanning V1 to V4.
capabilities::resolveCapability<Variant, N>(dispatcher, kVariants) in Capabilities.h walks the table in preference order and returns the first FeatureId for which FeatureDispatcher::hasFeature returns true. DeviceSession stores the resolved variant in std::optional<BatteryVariant>, std::optional<SmartShiftVariant>, std::optional<ReprogControlsVariant> and calls through those for the life of the session (src/core/DeviceSession.h lines 140 to 142). Adding support for a new variant is one table entry in the capabilities header / cpp; no DeviceSession or DeviceManager edits are required.
Protocol-level feature code (parsing one variant, building one request) lives in src/core/hidpp/features/ (AdjustableDPI, Battery, DeviceName, GestureV2, HiResWheel, ReprogControls, SmartShift, ThumbWheel). Features without multiple variants (AdjustableDPI, HiResWheel, ThumbWheel, DeviceName, GestureV2) are called directly against their FeatureId; only Battery, SmartShift, and ReprogControls currently need the dispatch-table layer.
hidpp::FeatureDispatcher (src/core/hidpp/FeatureDispatcher.{h,cpp}) owns the per-device feature index table. HID++ 2.0 assigns each feature a device-specific 8-bit index at runtime (Root is always 0x00, the rest vary), so every feature call has to resolve FeatureId -> index before building a report. callAsync assigns a rotating softwareId (1 to 15) so responses can be routed back to the original caller even when multiple requests are in flight; handleResponse is invoked by DeviceManager whenever an incoming report has non-zero softwareId (that is, a response rather than an unsolicited notification).
Methods:
| Method | Purpose |
|---|---|
enumerate(Transport *transport, uint8_t deviceIndex) |
Iterates the kKnownFeatures array, sends Root.getFeatureID(featureId) for each, and populates m_featureMap. Returns true on success. |
setFeatureTable(std::vector<std::pair<FeatureId, uint8_t>> table) |
Test hook: bypasses enumeration so unit tests can inject a deterministic feature map. |
featureIndex(FeatureId id) |
Returns the resolved index as std::optional<uint8_t>. |
hasFeature(FeatureId id) |
Boolean predicate used by capability gates. |
setFeatureIndex(FeatureId id, uint8_t index) |
Directly sets one entry in the feature map (used during capability resolution). |
call(Transport *transport, uint8_t deviceIndex, FeatureId feature, uint8_t functionId, std::span<const uint8_t> params = {}) |
Synchronous: resolves the index, builds the report, sends via Transport::sendRequest, and returns the response. |
callAsync(Transport *transport, uint8_t deviceIndex, FeatureId feature, uint8_t functionId, std::span<const uint8_t> params = {}, ResponseCallback callback = nullptr) |
Async: assigns a rotating softwareId, stores the ResponseCallback keyed on it, and returns the softwareId used. |
handleResponse(const Report &report) |
Called by DeviceManager when an incoming report has non-zero softwareId; looks up the pending callback and invokes it. Returns true when consumed. |
FeatureDispatcher does not own the transport; DeviceSession does. It holds no state about the hidraw fd, only the resolved feature map and pending callbacks.
The CommandProcessor exists to solve a specific problem: HwError flooding.
When a profile switch happens, Logitune needs to send many HID++ commands in rapid succession (divert 6 buttons + set DPI + set SmartShift + set scroll config + set thumb wheel = ~10 commands). Sending them all at once causes HwError (error code 0x04) responses because the device's internal command processor cannot keep up.
sequenceDiagram
participant App as DeviceManager
participant CQ as CommandProcessor
participant Timer as QTimer (10ms)
participant FD as FeatureDispatcher
participant TR as Transport
App->>CQ: enqueue(SetDPI, ...)
App->>CQ: enqueue(SetSmartShift, ...)
App->>CQ: enqueue(DivertButton, ...)
App->>CQ: enqueue(DivertButton, ...)
Note over CQ: Queue: [SetDPI, SetSmartShift, Divert, Divert]
Timer->>CQ: processNext()
CQ->>FD: callAsync(SetDPI, ...)
FD->>TR: sendRequestAsync(report)
Note over CQ: Wait 10ms
Timer->>CQ: processNext()
CQ->>FD: callAsync(SetSmartShift, ...)
Note over CQ: Wait 10ms
Timer->>CQ: processNext()
CQ->>FD: callAsync(DivertButton, ...)
Note over CQ: Continue until queue empty
CQ-->>App: queueDrained()
Key properties:
-
10ms inter-command delay (
kInterCommandDelayMs = 10) β enough for the device to process each command -
3 retries (
kMaxRetries = 3) with 50ms retry delay -
Main thread only β uses
QTimer, no mutex needed, no fd contention withQSocketNotifier -
Created after feature enumeration β the command processor is instantiated inside
enumerateAndSetup()after the feature table is populated
FeatureDispatcher::callAsync() uses a rotating softwareId (1-15) to match responses to requests:
sequenceDiagram
participant CQ as CommandProcessor
participant FD as FeatureDispatcher
participant TR as Transport
participant Notif as QSocketNotifier
CQ->>FD: callAsync(feature, fn, params, callback)
Note over FD: Assign softwareId = 3 (rotating 1-15)
FD->>TR: sendRequestAsync(report with swId=3)
Note over TR: Later, device responds...
Notif->>Notif: hidraw fd readable
Notif->>Notif: readReport -> parse Report
Note over Notif: report.softwareId = 3 (non-zero)
Notif->>FD: handleResponse(report)
Note over FD: Look up callback for swId=3
FD->>FD: callback(report)
Note over FD: Remove pending callback
The softwareId field (lower 4 bits of byte[3] in HID++ reports) distinguishes responses from notifications:
- softwareId = 0 β unsolicited notification from the device (battery change, button press, wheel rotation)
- softwareId 1-15 β response to a specific request sent by the host
This was a critical fix: without it, async responses from thumb wheel SetReporting were being misinterpreted as thumb wheel rotation events (the "delta=256 bug").
struct Profile { int version = 1; QString name; QString icon; int dpi = 1000; bool smartShiftEnabled = true; int smartShiftThreshold = 128; bool smoothScrolling = false; QString scrollDirection = "standard"; // "standard" or "natural" bool hiResScroll = true; std::array<ButtonAction, 16> buttons; // indexed by ControlDescriptor::buttonIndex std::map<QString, ButtonAction> gestures; // "up","down","left","right","click" QString thumbWheelMode = "scroll"; // "scroll", "zoom", "volume", "none" bool thumbWheelInvert = false; };
ProfileEngine is the profile domain layer. It owns every Profile known to the app, persists them to disk, and answers two questions: "which profile should the UI show for this device?" and "which profile is currently applied to this device's hardware?" Services read and write through its API; no other class touches profile files directly.
graph TB
subgraph "ProfileEngine"
Cache["In-Memory Cache<br/>QMap of serial to Profile"]
Disk["Disk Storage<br/>~/.config/Logitune/devices/{serial}/profiles/"]
Bindings["App Bindings<br/>app-bindings.conf"]
Display["displayProfile<br/>(what UI shows)"]
Hardware["hardwareProfile<br/>(what device runs)"]
end
subgraph "Files"
Default["default.conf"]
Firefox["firefox.conf"]
VSCode["code.conf"]
AppBindConf["app-bindings.conf"]
end
Cache --> Default
Cache --> Firefox
Cache --> VSCode
Bindings --> AppBindConf
Display --> Cache
Hardware --> Cache
What each block does:
-
In-memory cache β a
QMap<QString, QMap<QString, Profile>>keyed by(deviceSerial, profileName).cachedProfile(serial, name)is the mutable accessor everything else uses; it loads from disk on first access and returns by reference so in-place edits are picked up on the nextsaveProfileToDisk(serial, name). -
Disk storage β each device gets its own directory under
~/.config/Logitune/devices/{serial}/profiles/. Profiles are named{profileName}.conf(one file per profile). Thedefault.confis always present; app-specific profiles are created on demand. -
App bindings β
app-bindings.confmaps window manager class (e.g.google-chrome) to a profile name.profileForApp(serial, wmClass)consults this map; anything unmapped falls back todefault. Mutations go throughcreateProfileForApp(serial, wmClass, profileName)andremoveAppProfile(serial, wmClass). -
displayProfile β per-device name of the profile the UI is currently viewing/editing. Updated when the user clicks a profile tab. Emits
deviceDisplayProfileChanged(serial, profile). -
hardwareProfile β per-device name of the profile currently applied to the hardware. Updated when the focused window changes and
ProfileOrchestratorpushes a new profile. EmitsdeviceHardwareProfileChanged(serial, profile).
Display and hardware pointers are independent on purpose. See Display vs Hardware Profile.
Methods:
| Method | Purpose |
|---|---|
registerDevice(const QString &serial, const QString &configDir) |
Initializes the per-device context, pointing it at configDir under ~/.config/Logitune/devices/. |
hasDevice(const QString &serial) |
Returns true if a device context has been registered for serial. |
cachedProfile(const QString &serial, const QString &name) |
Mutable accessor; lazy-loads from disk on first touch and returns a reference so callers can edit in place. |
profileNames(const QString &serial) |
Returns the list of profile names currently known for serial. |
displayProfile(const QString &serial) |
Returns the name of the profile the UI is currently viewing. |
hardwareProfile(const QString &serial) |
Returns the name of the profile currently applied to the device hardware. |
profileForApp(const QString &serial, const QString &wmClass) |
Looks up the app binding; returns "default" if unmapped. |
setDisplayProfile(const QString &serial, const QString &name) |
Switches the UI-viewed profile and emits deviceDisplayProfileChanged. |
setHardwareProfile(const QString &serial, const QString &name) |
Switches the hardware-active profile and emits deviceHardwareProfileChanged. |
saveProfileToDisk(const QString &serial, const QString &name) |
Persists the cached profile for (serial, name) to its .conf file. |
createProfileForApp(const QString &serial, const QString &wmClass, const QString &profileName) |
Adds or updates the wmClass to profileName entry in app-bindings.conf. |
removeAppProfile(const QString &serial, const QString &wmClass) |
Removes the app binding for wmClass. |
loadProfile(const QString &path) |
Static: parses a single .conf file into a Profile. |
saveProfile(const QString &path, const Profile &profile) |
Static: serializes a Profile to disk. |
loadAppBindings(const QString &path) |
Static: parses an app-bindings.conf into a QMap<QString, QString>. |
saveAppBindings(const QString &path, const QMap<QString, QString> &bindings) |
Static: serializes app bindings to disk. |
diff(const Profile &a, const Profile &b) |
Static: returns a ProfileDelta describing which fields differ. |
Signals:
| Signal | Emitted when |
|---|---|
deviceDisplayProfileChanged(const QString &serial, const Profile &profile) |
The UI-viewed profile changes, wired to ProfileOrchestrator::onDisplayProfileChanged in AppRoot::wireSignals. |
deviceHardwareProfileChanged(const QString &serial, const Profile &profile) |
The hardware-active profile changes. |
-
Device connects β
onDeviceSetupComplete()creates the profile directory under~/.config/Logitune/devices/<serial>/profiles/ -
First connect β seeds
default.conffrom current device hardware state (DPI, SmartShift, scroll config, button defaults from descriptor, default gestures) -
Profile load β
setDeviceConfigDir()scans the directory for.conffiles and loads them into the in-memory cache -
Focus change β
profileForApp(wmClass)looks up the app binding; if none found, returns "default" -
Hardware apply β
applyProfileToHardware()sends all profile settings via CommandProcessor - User edit β UI changes go through DeviceModel -> DeviceCommandHandler -> ProfileOrchestrator -> ProfileEngine cache -> disk save
- Cache vs disk β the cache is the source of truth during runtime; saves to disk are immediate but loads only happen at startup
The ProfileDelta struct tracks which fields changed between two profiles:
struct ProfileDelta { bool dpiChanged = false; bool smartShiftChanged = false; bool scrollChanged = false; bool buttonsChanged = false; bool gesturesChanged = false; };
This enables future optimizations where only changed settings are sent to hardware during profile switches.
Logitune uses a Model-View-ViewModel pattern where C++ models serve as the ViewModel layer between QML views and core logic.
The four services split into translators and a coordinator.
Translators do one focused conversion each and do not own multi-step flows:
-
ActiveDeviceResolverβ converts ViewModel state (selected index + device list) into a resolved active device pointer -
DeviceCommandHandlerβ converts ViewModel intent (UI slider drag, toggle click) into Model operations (DeviceSession::setDPI, etc.) -
ButtonActionDispatcherβ converts hardware events (button press, thumb wheel rotation) into domain actions (keystroke injection, app launch)
Coordinator β ProfileOrchestrator owns a multi-step workflow that reads and writes across both layers. onWindowFocusChanged alone does: ViewModel write (active wmClass), Model read (profile-for-app lookup), Model write (set hardware profile), Model command fan-out (apply DPI / SmartShift / scroll / buttons to session), cross-service signal emission (profileApplied to dispatcher), ViewModel write (hardware-active profile name). Seven steps spanning both layers per user window-focus change. That is coordination, not translation β which is why it warrants the MVVM-C "Coordinator" role rather than fitting into the same VM-to-Model bridge category as the translators.
Telling signals that something is a coordinator, not a translator:
- Holds pointers to many dependencies (8 on
ProfileOrchestrator; 1-3 on each translator) - Owns workflow entry points (
onWindowFocusChanged,onUserButtonChanged,onTabSwitched) rather than single-responsibility translators - Writes to both ViewModels and Model in the same method
graph LR
subgraph "View (QML)"
PointScroll[PointScrollPage.qml]
Buttons[ButtonsPage.qml]
EasySwitch[EasySwitchPage.qml]
Settings[SettingsPage.qml]
ProfileBar[AppProfileBar.qml]
end
subgraph "ViewModel (C++ Models)"
DM[DeviceModel]
BM[ButtonModel]
AM[ActionModel]
GAM[GestureActionModel]
PM[ProfileModel]
end
subgraph "Services (VM to Model bridges)"
DSel[ActiveDeviceResolver]
DCmd[DeviceCommandHandler]
BAD[ButtonActionDispatcher]
end
PO[ProfileOrchestrator<br/>Coordinator]
subgraph "Model (Core)"
DMgr[DeviceManager]
PE[ProfileEngine]
AE[ActionExecutor]
APR[ActionPresetRegistry]
IDI[IDesktopIntegration]
end
PointScroll --> DM
Buttons --> BM
Buttons --> AM
Buttons --> GAM
Buttons --> DM
EasySwitch --> DM
Settings --> DM
ProfileBar --> PM
DM -->|ChangeRequested| DCmd
BM -->|userActionChanged| PO
PM -->|profileSwitched| PO
AM -->|preset variant gate| APR
AM -->|live-binding gate| IDI
GAM -->|gestureMode filter| APR
GAM -->|gestureMode filter| IDI
DSel --> DM
DCmd --> DSel
DCmd --> DMgr
BAD --> DSel
BAD --> PE
BAD --> AE
BAD -->|resolve PresetRef| IDI
PO --> DSel
PO --> PE
PO --> AE
PO --> IDI
PO -->|pushDisplayValues| DM
PO -->|restoreButtons| BM
PO -->|setHwActive| PM
IDI -->|variantData| APR
classDef view fill:#831843,stroke:#f472b6,color:#fce7f3
classDef vm fill:#1e3a8a,stroke:#60a5fa,color:#dbeafe
classDef service fill:#064e3b,stroke:#34d399,color:#d1fae5
classDef coord fill:#4c1d95,stroke:#a78bfa,color:#ede9fe
classDef core fill:#78350f,stroke:#fbbf24,color:#fef3c7
class PointScroll,Buttons,EasySwitch,Settings,ProfileBar view
class DM,BM,AM,GAM,PM vm
class DSel,DCmd,BAD service
class PO coord
class DMgr,PE,AE,APR,IDI core
DeviceModel β QObject singleton exposed to QML. Provides:
- Device state (connected, name, battery, connection type)
- Settings (DPI, SmartShift, scroll, thumb wheel)
- Device descriptor info (images, hotspots, Easy-Switch slots)
- Display values that may differ from hardware (when viewing non-active profile)
- Logging control (enable/disable, bug report)
ButtonModel β QAbstractListModel with roles:
| Role | Type | Description |
|---|---|---|
ButtonIdRole |
int | Button index (0-7) |
ButtonNameRole |
QString | Display name from device descriptor |
ActionNameRole |
QString | Current action display name |
ActionTypeRole |
QString | Action type: "default", "keystroke", "gesture-trigger", etc. |
ActionModel β QAbstractListModel catalog of available actions:
| Role | Type | Description |
|---|---|---|
NameRole |
QString | Display name (e.g., "Copy") |
DescriptionRole |
QString | Help text |
ActionTypeRole |
QString | "default", "keystroke", "app-launch", "preset", etc. |
PayloadRole |
QString | Keystroke combo, app command, or preset id (when type is "preset") |
CategoryRole |
QString | Group label ("Workspace", "Window", "Media", "System", "Edit", "Navigation", "Device", "Other"). The picker uses ListView.section.property to render section headers between groups. Rows are stored ordered by category, then alphabetically. |
ProfileModel β QAbstractListModel for the profile tab bar:
| Role | Type | Description |
|---|---|---|
NameRole |
QString | Profile display name |
IconRole |
QString | Application icon name |
WmClassRole |
QString | Window manager class for app binding |
IsActiveRole |
bool | User's selected tab |
IsHwActiveRole |
bool | Currently active on hardware |
Models are registered as QML singletons in main.cpp:
qmlRegisterSingletonInstance("Logitune", 1, 0, "DeviceModel", controller.deviceModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "ButtonModel", controller.buttonModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "ActionModel", controller.actionFilterModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "GestureActionModel", controller.gestureActionFilterModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "ProfileModel", controller.profileModel()); qmlRegisterSingletonInstance("Logitune", 1, 0, "SettingsModel", controller.settingsModel());
ActionModel (the QML name) is bound to a filtering proxy β ActionFilterModel wraps the raw ActionModel catalog and hides entries the selected device can't execute (PR #82) plus preset rows whose DE doesn't support them. QML always binds through the filter, never the raw catalog. GestureActionModel is a second ActionFilterModel instance with gestureMode=true so the gesture sub-direction picker only sees actions the gesture-release dispatcher can fire (excludes gesture-trigger recursion and none). SettingsModel exposes the persisted user prefs (dark mode, logging, autostart, minimized, bug reports) as a single Q_PROPERTY surface.
flowchart LR
QML["QML import Logitune 1.0<br/>(any .qml file)"]
DM["DeviceModel<br/><i>rows: PhysicalDevice *</i>"]
BM["ButtonModel<br/><i>rows: visible buttons of<br/>selected device profile</i>"]
AFM["ActionModel<br/><i>filtered catalog minus<br/>device-incompatible rows</i>"]
GAM["GestureActionModel<br/><i>same catalog, gestureMode<br/>filter (no gesture-trigger)</i>"]
PM["ProfileModel<br/><i>rows: user's profiles</i>"]
SM["SettingsModel<br/><i>dark mode, logging,<br/>autostart, ...</i>"]
QML -->|singleton| DM
QML -->|singleton| BM
QML -->|singleton| AFM
QML -->|singleton| GAM
QML -->|singleton| PM
QML -->|singleton| SM
All six are registered in src/app/main.cpp against controller.xxxModel() accessors β AppRoot owns them, QML borrows them. No other QML-visible C++ classes.
EditorModel (src/app/models/EditorModel.{h,cpp}) is the controller for descriptor-editor mode, despite the "Model" name. It is only instantiated when the app is launched with --edit; in normal runs the pointer is null and none of its signals fire. Exposed to QML, it stores pending JSON edits per descriptor path, runs an undo / redo stack, and persists through DescriptorWriter when the user clicks Save.
Responsibilities:
-
Pending-edit buffering. Edits do not go straight to disk.
m_pendingEdits(QHash<QString, QJsonObject>) holds one in-memorydescriptor.jsonper device path;ensurePending(path)lazy-loads it from disk on first access.pushStateToActiveDevice()applies the pending JSON to the liveJsonDeviceviaJsonDevice::refreshFromObjectso QML bindings see the change immediately even before the user saves. -
Per-path undo stack.
m_undoStacksandm_redoStacksare keyed on device path, so switchingactiveDevicePathpreserves each device's history independently. Every mutator creates anEditCommandwithbefore/afterJSON payloads and callspushCommandto push onto the undo stack (clearing the redo stack). -
External-change detection. A
QFileSystemWatcher(m_watcher) watches each loadeddescriptor.json. OnfileChanged,onExternalFileChangedeither suppresses the event (ifm_selfWrittenPathsshows we just wrote it ourselves), emitsexternalChangeDetected(path)when the user has unsaved edits, or asksDeviceRegistry::reloadto pick up the new on-disk state otherwise. -
Atomic save.
save()marks the path inm_selfWrittenPaths(to suppress the filesystem-watcher echo), callsm_writer.write(path, pendingJson), then clears pending state and asksDeviceRegistryto reload on success or emitssaveFailed(path, error)on failure.
Q_PROPERTY surface: editing, hasUnsavedChanges, canUndo, canRedo, activeDevicePath.
Methods:
| Method | Purpose |
|---|---|
editing() |
Returns true when the app was launched with --edit. |
hasUnsavedChanges() |
Returns true if the active device path has pending edits. |
canUndo() |
Returns true if the active device's undo stack is non-empty. |
canRedo() |
Returns true if the active device's redo stack is non-empty. |
activeDevicePath() |
Returns the descriptor path currently being edited. |
pendingFor(const QString &path) |
Q_INVOKABLE: returns the in-memory pending JSON for path as a QVariantMap. |
updateSlotPosition(int idx, double xPct, double yPct) |
Q_INVOKABLE: edits an Easy-Switch slot position and pushes an EditCommand. |
updateHotspot(int hotspotIndex, double xPct, double yPct, const QString &side, double labelOffsetYPct) |
Q_INVOKABLE: edits a button hotspot. |
updateScrollHotspot(int hotspotIndex, double xPct, double yPct, const QString &side, double labelOffsetYPct) |
Q_INVOKABLE: edits a scroll-wheel or thumb-wheel hotspot. |
updateText(const QString &field, int index, const QString &value) |
Q_INVOKABLE: edits a named string field (e.g. control defaultName). |
undo() |
Q_INVOKABLE: pops the top EditCommand off the undo stack and pushes onto the redo stack. |
redo() |
Q_INVOKABLE: pops the top EditCommand off the redo stack and reapplies. |
save() |
Q_INVOKABLE: marks the path in m_selfWrittenPaths, writes via DescriptorWriter, and asks DeviceRegistry to reload. |
reset() |
Q_INVOKABLE: clears pending edits and undo / redo stacks for the active path. |
replaceImage(const QString &role, const QString &sourcePath) |
Q_INVOKABLE: copies a new image into the descriptor directory for role "front" / "side" / "back". |
Signals:
| Signal | Emitted when |
|---|---|
dirtyChanged() |
The dirty flag for the active path flips. |
undoStateChanged() |
The undo or redo stack for the active path changes (push, pop, or active-path switch). |
activeDevicePathChanged() |
setActiveDevicePath changes the currently-edited path. |
saved(const QString &path) |
save() successfully writes path. |
saveFailed(const QString &path, const QString &error) |
save() fails with an I/O or JSON error. |
externalChangeDetected(const QString &path) |
The QFileSystemWatcher reports a change to path while the user has unsaved edits. |
Slots:
| Slot | Responds to |
|---|---|
setActiveDevicePath(const QString &path) |
DeviceModel::selectedChanged in editor mode, wired in AppRoot::startMonitoring. |
onExternalFileChanged(const QString &filePath) |
QFileSystemWatcher::fileChanged, wired internally by EditorModel. |
classDiagram
class IDesktopIntegration {
<<abstract>>
+start()
+available() bool
+desktopName() QString
+detectedCompositors() QStringList
+variantKey() QString
+resolveNamedAction(id) optional~ButtonAction~
+blockGlobalShortcuts(bool block)
+runningApplications() QVariantList
+activeWindowChanged(wmClass, title) signal
}
class LinuxDesktopBase {
+runningApplications()
#resolveDesktopFile(appId) QString
#desktopDirs() QStringList
}
class KDeDesktop {
+focusChanged(resourceClass, title, desktopFileName)
-m_kwin : QDBusInterface
-m_pollTimer : QTimer
}
class GnomeDesktop {
+focusChanged(appId, title)
-ensureExtensionInstalled() bool
-detectAppIndicatorStatus()
-m_appIndicatorStatus : AppIndicatorStatus
}
class GenericDesktop {
+start()
}
IDesktopIntegration <|-- LinuxDesktopBase
LinuxDesktopBase <|-- KDeDesktop
LinuxDesktopBase <|-- GnomeDesktop
IDesktopIntegration <|-- GenericDesktop
GnomeDesktop (Wayland-only) auto-installs and enables a GNOME Shell extension on first launch that pipes focus events to a D-Bus-registered callback in-process β event-driven, no polling. It also detects AppIndicator support via org.kde.StatusNotifierWatcher so the tray icon can tell users when to install gnome-shell-extension-appindicator. KDeDesktop uses a KWin script + polling fallback (the KWin 6 signal quirk).
KDeDesktop uses a KWin script to track window focus changes:
sequenceDiagram
participant KWin as KWin Compositor
participant Script as Focus Watcher Script
participant DBus as D-Bus Session Bus
participant KDE as KDeDesktop
participant AC as AppRoot
Note over KDE: On start, register D-Bus service com.logitune.app
KDE->>KWin: loadScript(logitune_focus_watcher.js)
KDE->>KWin: start()
Note over Script: workspace.windowActivated.connect(update)
KWin->>Script: windowActivated
Script->>DBus: callDBus('com.logitune.app', '/FocusWatcher', focusChanged, resourceClass, caption, desktopFileName)
DBus->>KDE: focusChanged(resourceClass, title, desktopFileName)
KDE->>KDE: resolveDesktopFile(resourceClass)
Note over KDE: 1. Use desktopFileName if present<br/>2. Search .desktop files by name/StartupWMClass<br/>3. Fall back to resourceClass
KDE->>AC: activeWindowChanged(appId, title)
A critical problem: the same application can have different identifiers depending on how it's packaged:
- Zoom:
resourceClass="zoom", but.desktopfile isus.zoom.Zoom.desktop - Firefox:
desktopFileName="org.mozilla.firefox" - Native KDE apps:
desktopFileName="org.kde.dolphin"
resolveDesktopFile() searches these directories:
/usr/share/applications~/.local/share/applications-
/var/lib/flatpak/exports/share/applications(Flatpak apps installed on the host) ~/.local/share/flatpak/exports/share/applications/var/lib/snapd/desktop/applications
It matches by:
- Last component of the
.desktopfilename (e.g., "Zoom" from "us.zoom.Zoom") -
StartupWMClassfield in the.desktopfile
Results are cached in m_resolveCache to avoid repeated filesystem scans.
During keystroke capture (when the user is pressing a key combo to assign to a button), KDE global shortcuts are temporarily disabled via:
QDBusMessage msg = QDBusMessage::createMethodCall( "org.kde.kglobalaccel", "/kglobalaccel", "org.kde.KGlobalAccel", "blockGlobalShortcuts"); msg << block; QDBusConnection::sessionBus().call(msg, QDBus::NoBlock);
This prevents Ctrl+Super+Left (assigned to "switch desktop left") from actually switching desktops while the user is trying to capture it as a button binding.
GnomeDesktop (src/core/desktop/GnomeDesktop.{h,cpp}) is the Wayland-only GNOME implementation. On start() it checks XDG_SESSION_TYPE == wayland and pings org.gnome.Shell via D-Bus to confirm GNOME Shell is reachable (both gates return available() = false if they fail). ensureExtensionInstalled() copies the bundled logitune-focus@logitune.com Shell extension into ~/.local/share/gnome-shell/extensions/ and enables it via gnome-extensions enable; the extension then calls back into the app via D-Bus rather than the app polling for focus.
After the extension is in place, GnomeDesktop registers the service com.logitune.app and the object /FocusWatcher on the session bus (QDBusConnection::ExportAllSlots) so the extension's focusChanged(appId, title) calls land on the matching slot.
detectAppIndicatorStatus() is a separate probe for system-tray support. It checks whether org.kde.StatusNotifierWatcher is registered on the session bus (the D-Bus interface that any AppIndicator-compatible extension registers). The result populates m_appIndicatorStatus (one of AppIndicatorUnknown, AppIndicatorNotInstalled, AppIndicatorDisabled, AppIndicatorActive) so the UI can tell GNOME users to install gnome-shell-extension-appindicator when the tray icon will not render.
HyprlandDesktop (src/core/desktop/HyprlandDesktop.{h,cpp}) is the Hyprland implementation. It connects to Hyprland's event socket at $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock, listens for activewindow events, resolves the reported window class through the shared .desktop lookup, and emits activeWindowChanged for automatic per-app profile switching. Its semantic presets are resolved from the user's live Hyprland binds by querying the command socket with j/binds; unsupported or unbound presets are hidden. blockGlobalShortcuts(bool) is intentionally a no-op because Hyprland does not expose a stable compositor-wide shortcut-block API.
GenericDesktop (src/core/desktop/GenericDesktop.{h,cpp}) is the no-op fallback for desktop environments that are neither KDE, GNOME, nor Hyprland (XFCE, MATE, Cinnamon, sway, etc.). start() does nothing, available() returns true unconditionally, desktopName() returns "Generic", detectedCompositors() returns an empty list, and blockGlobalShortcuts(bool) is a no-op. The practical consequence: per-app profile switching is disabled on these environments because there is no focus-tracking signal; the user can still switch profiles manually through the profile tab bar, and every other feature (HID++, button remapping, uinput injection) continues to work.
IInputInjector (src/core/interfaces/IInputInjector.h) is the abstract interface through which the app delivers synthesized input: init(), injectKeystroke(combo), injectCtrlScroll(direction), injectHorizontalScroll(direction), sendDBusCall(spec), launchApp(command). ActionExecutor holds a non-owning pointer to one; tests substitute MockInjector to capture the calls for assertions.
UinputInjector (src/core/input/UinputInjector.{h,cpp}) is the production /dev/uinput implementation. init() opens /dev/uinput with O_WRONLY | O_NONBLOCK, registers the key and relative-axis bits the app can emit (modifiers, arrows, media keys, F1 to F12, A to Z, 0 to 9, plus REL_WHEEL and REL_HWHEEL), sets up a uinput_setup with vendor 0x046d and product 0x0001 under the name logitune-virtual-kbd, and finalizes with UI_DEV_CREATE. If any step fails (most often because /dev/uinput is not accessible under the user's group or the logitune udev rules are missing), init() returns false and all subsequent injectKeystroke calls are silent no-ops.
Keystroke chord parsing lives in UinputInjector::parseKeystroke(combo) (static, unit-tested directly). It splits on +, maps modifier tokens (Ctrl, Shift, Alt, Super / Meta), special keys (Tab, Space, Enter, Up, Down, Home, PageUp, VolumeUp, Print, BrightnessDown, etc.), symbols (Minus, Equal, LeftBrace, Semicolon, Comma), and letters / digits to KEY_* codes from <linux/input-event-codes.h>. The bare "+" chord is handled before the split to preserve the KEY_KPPLUS case. ActionExecutor::parseKeystroke forwards to this function so tests can cover the parser without constructing an injector.
Key emission in injectKeystroke presses all resolved keycodes in order (emitKey(k, true) + emitSync()), then releases them in reverse order. injectCtrlScroll(direction) wraps a REL_WHEEL write in a KEY_LEFTCTRL press / release so applications that bind zoom to Ctrl+scroll respond. injectHorizontalScroll(direction) writes a REL_HWHEEL event for the thumb-wheel scroll mode. launchApp(command) uses QProcess::startDetached; sendDBusCall(spec) parses a four-part service,path,interface,method string and dispatches through QDBusConnection::sessionBus().send.
DeviceManager is the hardware-facing entry point of the core library. Its job is to detect Logitech HID++ devices on the system, open transports to them, group transports that belong to the same physical unit, and notify the rest of the app when a device appears or disappears.
Responsibilities:
-
Hidraw enumeration. On
start(), scans/sys/class/hidraw/for nodes matching Logitech vendor IDs (direct + Bolt/Unifying receiver variants). For each candidate, probes whether the device speaks HID++ (report descriptor check) and which feature set it advertises. -
Transport creation. For each probed device, creates a
DeviceSessionwrapping the hidraw file descriptor and aCommandProcessorwith its own pacing state. -
Physical device grouping. Calls
DeviceInfo.getSerialover HID++ to get the unit serial, then groupsDeviceSessioninstances with the same serial under a singlePhysicalDevice. A mouse that shows up twice (once via Bolt receiver, once via Bluetooth) becomes onePhysicalDevicewith two transports. Details in the transport aggregation section. -
udev event handling. Subscribes to libudev on the session bus for
add/removeevents on hidraw nodes. Onadd, probes the new node and either creates a newPhysicalDeviceor attaches the transport to an existing one. Onremove, detaches the transport; if the last transport for aPhysicalDeviceis gone, removes the device. -
Unknown device reporting. If a device with a known Logitech VID but an unrecognized PID appears, emits
unknownDeviceDetected(pid).DeviceFetchersubscribes and tries to pull a community descriptor for that PID from the GitHub-hosted device database. -
Simulation mode.
simulateAllFromRegistry()bypasses udev and HID++ entirely, synthesizing one fakeDeviceSession+PhysicalDeviceper descriptor inDeviceRegistry. Used by the--simulate-allCLI flag to let developers visually inspect every community descriptor without physical hardware. Never called in production.
Methods:
| Method | Purpose |
|---|---|
start() |
Initializes the libudev monitor and scans existing /dev/hidraw* nodes. |
simulateAllFromRegistry() |
--simulate-all entry point: synthesizes one fake DeviceSession + PhysicalDevice per descriptor in DeviceRegistry. Never called in production. |
sessions() |
Returns the owning std::vector<std::unique_ptr<DeviceSession>>. |
sessionById(const QString &id) |
Lookup a DeviceSession* by device id, or nullptr. |
sessionByPid(uint16_t pid) |
Lookup a DeviceSession* by Logitech product id, or nullptr. |
physicalDevices() |
Returns a QList<PhysicalDevice*> of currently-attached devices. |
physicalDeviceBySerial(const QString &serial) |
Lookup a PhysicalDevice* by HID++ unit serial, or nullptr. |
activeDevice() |
Backward-compat: returns the first session's descriptor. |
isReceiver(uint16_t pid) |
Static: true if pid is a known Bolt/Unifying receiver. |
deviceIndexForDirect() |
Static: the HID++ device index (0xFF) used for direct (non-receiver) connections. |
deviceIndexForReceiver(int slot) |
Static: the HID++ device index for receiver slot slot (1 to 6). |
Signals:
| Signal | Emitted when |
|---|---|
physicalDeviceAdded(PhysicalDevice *device) |
A new HID++ unit serial is first seen. AppRoot::onPhysicalDeviceAdded wires per-device signals into ButtonActionDispatcher and ProfileOrchestrator. |
physicalDeviceRemoved(PhysicalDevice *device) |
The last transport of a device is gone (not on transport switch). DeviceModel drops its row, ButtonActionDispatcher drops gesture state, ProfileOrchestrator cleans per-device state. |
unknownDeviceDetected(uint16_t pid) |
A device with a known Logitech VID but unrecognized PID appears. Wired to DeviceFetcher::fetchForPid for on-demand descriptor fetching. |
What it does NOT do:
- Does not own device settings (DPI, SmartShift, etc.). That is
ProfileEngine. - Does not parse HID++ responses. That is
FeatureDispatcher+DeviceSession. - Does not write profiles to hardware. That is
ProfileOrchestratorviaDeviceSessionsetters.
A single MX Master 3S mouse typically appears on the host as two hidraw nodes when both transports are active β once via the Bolt/Unifying receiver, once via direct Bluetooth. HID++ unit serial (from DeviceInfo.getSerial) identifies them as the same physical unit. src/core/PhysicalDevice.{h,cpp} is the abstraction that collapses them:
- Owned by
DeviceManager, keyed by serial. - Holds a non-owning list of
DeviceSession *transports. - Exposes a single
primary()pointer β commands route there. - Picks primary based on connection state: if the current primary goes stale (udev remove, ping timeout), switches to any other connected transport without the UI seeing a disconnect event.
- Emits
stateChanged/deviceNameChanged/batteryChangedonce per underlying transition, not per transport β models bind toPhysicalDevice, not the rawDeviceSession.
The UI, DeviceModel, ProfileEngine, and tray all deal in PhysicalDevice *. DeviceSession * is an implementation detail of the transport layer.
flowchart LR
DM["DeviceManager<br/><i>keyed by unit serial</i>"]
PD["PhysicalDevice<br/><i>serial = 'ABCD...'</i>"]
DS1["DeviceSession<br/><i>Bolt receiver</i><br/>β
primary"]
DS2["DeviceSession<br/><i>Bluetooth direct</i><br/>standby"]
DM -->|owns| PD
PD -->|non-owning, active| DS1
PD -.->|non-owning, fallback| DS2
DS1 -. "pings fail β<br/>PhysicalDevice swaps primary" .-> DS2
When the active transport drops (udev remove, HID++ ping timeout), PhysicalDevice::setPrimary() picks any remaining connected session. The UI never sees a disconnect event β stateChanged still fires, but DeviceModel's row for this serial stays.
Methods:
| Method | Purpose |
|---|---|
serial() |
Returns the HID++ unit serial this aggregator is keyed on. |
primary() |
Returns the currently-active DeviceSession* that commands route to, or nullptr. |
transports() |
Returns the full QList<DeviceSession*> of attached transports (primary plus alternates). |
transportCount() |
Number of currently-attached transports. |
attachTransport(DeviceSession *session) |
Registers a transport; promotes to primary if none exists or current primary is offline. |
detachTransport(DeviceSession *session) |
Removes a transport and promotes an alternate if needed. Returns true when no transports remain. |
isConnected() |
Delegates to the primary transport. |
deviceName() |
Delegates to the primary transport. |
connectionType() |
Delegates to the primary transport ("bolt", "bluetooth", etc.). |
batteryLevel() |
Delegates to the primary transport. |
batteryCharging() |
Delegates to the primary transport. |
currentDPI() / minDPI() / maxDPI() / dpiStep()
|
Delegates DPI accessors to the primary transport. |
smartShiftEnabled() / smartShiftThreshold()
|
Delegates SmartShift state to the primary transport. |
scrollHiRes() / scrollInvert() / scrollRatchet()
|
Delegates scroll config to the primary transport. |
thumbWheelMode() / thumbWheelInvert() / thumbWheelDefaultDirection()
|
Delegates thumb-wheel state to the primary transport. |
currentHost() / hostCount() / isHostPaired(int host)
|
Delegates Easy-Switch host state to the primary transport. |
deviceSerial() |
Delegates to the primary transport (may differ from the aggregator key early in enumeration). |
firmwareVersion() |
Delegates to the primary transport. |
descriptor() |
Returns the resolved const IDevice* descriptor from the primary transport. |
Signals:
| Signal | Emitted when |
|---|---|
stateChanged() |
Anything observable changes (primary swap, enumerate complete, disconnect, battery update). DeviceModel emits dataChanged in response. |
transportSetupComplete() |
Any transport finishes setupComplete (fresh enumerate or reconnect re-enumerate). Wired per-device in AppRoot::onPhysicalDeviceAdded to ProfileOrchestrator::onTransportSetupComplete. |
gestureRawXY(int16_t dx, int16_t dy) |
Forwarded from the active session's gesture notifications. |
divertedButtonPressed(uint16_t controlId, bool pressed) |
Forwarded from the active session's diverted-button notifications. |
thumbWheelRotation(int delta) |
Forwarded from the active session's thumb-wheel notifications. |
smartShiftChanged(bool enabled, int threshold) |
Per-property relay of DeviceSession::smartShiftChanged. |
scrollConfigChanged() |
Per-property relay of DeviceSession::scrollConfigChanged. |
thumbWheelModeChanged() |
Per-property relay of DeviceSession::thumbWheelModeChanged. |
currentDPIChanged() |
Per-property relay of DeviceSession::currentDPIChanged. |
DeviceSession (src/core/DeviceSession.{h,cpp}) is the per-transport object: one hidraw fd, one HID++ conversation, one set of per-device runtime state. A PhysicalDevice owns one or more of these; everything that writes to hardware eventually calls setter methods here.
Responsibilities:
-
Owns the protocol stack for one transport. Holds
unique_ptr<HidrawDevice>,unique_ptr<Transport>,unique_ptr<FeatureDispatcher>, andunique_ptr<CommandProcessor>as members (DeviceSession.hlines 135 to 138).enumerateAndSetup()is the single entry point that probes features, reads initial state (battery, DPI, SmartShift, scroll config, thumb wheel, Easy-Switch hosts), and constructs theCommandProcessoronly once the feature table is ready. -
Resolves capability variants at enumeration. Stores
optional<BatteryVariant>,optional<SmartShiftVariant>,optional<ReprogControlsVariant>(DeviceSession.hlines 140 to 142) populated viacapabilities::resolveCapability. All later reads and writes go through the resolved variant, soDeviceSessionhas no per-generation branching of its own. -
Hardware setter API.
setDPI(int),setSmartShift(bool, int),setScrollConfig(bool, bool),setThumbWheelMode(QString, bool),divertButton(uint16_t, bool, bool rawXY), and the Q_INVOKABLEcycleDpi(). Each enqueues one command on theCommandProcessorwith a 10 ms pacing gap rather than writing directly. -
Read-only getters.
currentDPI,minDPI,maxDPI,dpiStep,smartShiftEnabled,smartShiftThreshold,scrollHiRes,scrollInvert,scrollRatchet,thumbWheelMode,thumbWheelInvert,thumbWheelDefaultDirection,batteryLevel,batteryCharging,currentHost,hostCount,isHostPaired(int). These are projections ofm_*members, updated by notification handlers and by responses to our requests. -
Notification dispatch.
handleNotification(const Report&)is called byDeviceManagerfrom the hidrawQSocketNotifierfor reports withsoftwareId == 0. Routes thumb wheel, gesture, button, battery, and link-state notifications to the right per-feature handler. -
Sleep / wake detection.
checkSleepWake()runs on the battery poll timer and comparesm_lastResponseTimeagainstkSleepThresholdMs.touchResponseTime()is called before intentional hardware writes so a profile switch does not look like a wake event. See Sleep/Wake Detection. -
Connection tracking.
m_connected,m_deviceName,m_deviceSerial,m_firmwareVersion, andm_activeDevice(non-owningconst IDevice*pointer to the descriptor matched viaDeviceRegistry) make up the session's identity.disconnectCleanup()tears down logical state without closing the fd (see Bolt Receiver DJ Notifications). -
Simulation hook.
applySimulation(const IDevice*, QString fakeSerial)is the--simulate-allentry point; it fakes a connected state against a registry descriptor so the UI can render without real hardware. Never called in production.
Methods:
| Method | Purpose |
|---|---|
enumerateAndSetup() |
Single entry point: probes features, reads initial state, resolves capability variants, and constructs the CommandProcessor. |
cycleDpi() |
Q_INVOKABLE: advances to the next DPI stop in the effective ring. |
setDPI(int value) |
Enqueues an AdjustableDPI.setSensorDpi command on the CommandProcessor. |
setSmartShift(bool enabled, int threshold) |
Enqueues a SmartShift set command using the resolved variant. |
setScrollConfig(bool hiRes, bool invert) |
Enqueues a HiResWheel.setWheelMode command. |
divertButton(uint16_t controlId, bool divert, bool rawXY = false) |
Enqueues a ReprogControlsV4.setCidReporting command. |
setThumbWheelMode(const QString &mode, bool invert = false) |
Enqueues a ThumbWheel.setThumbwheelReporting command. |
flushCommandProcessor() |
Drains the pending command queue (used during profile switches and teardown). |
touchResponseTime() |
Updates m_lastResponseTime so an intentional hardware write does not look like a wake event. |
handleNotification(const hidpp::Report &report) |
Called by DeviceManager from the hidraw QSocketNotifier for reports with softwareId == 0; routes to the right per-feature handler. |
disconnectCleanup() |
Tears down logical state without closing the hidraw fd (soft disconnect for DJ notifications). |
applySimulation(const IDevice *dev, const QString &fakeSerial) |
--simulate-all entry point: fakes a connected state against a registry descriptor. Never called in production. |
deviceId() / descriptor() / devicePid() / deviceVid() / deviceSerial() / deviceName() / connectionType() / isConnected() / firmwareVersion()
|
Identity accessors. |
batteryLevel() / batteryCharging()
|
Battery state accessors. |
currentDPI() / minDPI() / maxDPI() / dpiStep()
|
DPI state accessors (projections of m_* members). |
smartShiftEnabled() / smartShiftThreshold()
|
SmartShift state accessors. |
scrollHiRes() / scrollInvert() / scrollRatchet()
|
Scroll config accessors. |
thumbWheelMode() / thumbWheelInvert() / thumbWheelDefaultDirection()
|
Thumb-wheel state accessors. |
currentHost() / hostCount() / isHostPaired(int host)
|
Easy-Switch host state accessors. |
features() / transport() / deviceIndex() / device()
|
Protocol-stack accessors (return the owned FeatureDispatcher, Transport, HID++ index, and HidrawDevice). |
effectiveDpiRing(const std::vector<int> &curated, bool adjustableDpi, int minDpi, int maxDpi, int step) |
Static pure helper, unit-tested in tests/test_dpi_cycle_ring.cpp. |
nextDpiInRing(const std::vector<int> &ring, int currentDpi) |
Static pure helper, unit-tested in tests/test_dpi_cycle_ring.cpp. |
Signals:
| Signal | Emitted when |
|---|---|
setupComplete() |
enumerateAndSetup finishes. Forwarded via PhysicalDevice::transportSetupComplete to ProfileOrchestrator::onTransportSetupComplete. |
disconnected() |
The transport goes offline (DJ notification or hidraw read failure). |
deviceWoke() |
The sleep/wake detector observes a response after a gap greater than kSleepThresholdMs. |
batteryChanged(int level, bool charging) |
Battery level or charging state changes, mirrored up to PhysicalDevice. |
smartShiftChanged(bool enabled, int threshold) |
SmartShift mode or threshold changes, mirrored up to PhysicalDevice. |
currentDPIChanged() |
DPI set response is received, mirrored up to PhysicalDevice. |
scrollConfigChanged() |
Scroll config is refreshed, mirrored up to PhysicalDevice. |
thumbWheelModeChanged() |
Thumb-wheel mode or invert is refreshed, mirrored up to PhysicalDevice. |
divertedButtonPressed(uint16_t controlId, bool pressed) |
A diverted button press or release is received; consumed by ButtonActionDispatcher via PhysicalDevice. |
gestureRawXY(int16_t dx, int16_t dy) |
A DivertedRawXYEvent is received during an active gesture; consumed by ButtonActionDispatcher via PhysicalDevice. |
thumbWheelRotation(int delta) |
A diverted thumb-wheel rotation is received; consumed by ButtonActionDispatcher via PhysicalDevice. |
unknownDeviceDetected(uint16_t pid) |
A receiver-attached device's PID is not in DeviceRegistry. Forwarded up to DeviceManager::unknownDeviceDetected. |
flowchart TD
Start[DeviceManager::start] --> InitUdev[Initialize libudev monitor]
InitUdev --> Scan[scanExistingDevices]
Scan --> ForEach{For each /dev/hidrawN}
ForEach --> CheckVendor{Vendor == 0x046d?}
CheckVendor -->|No| ForEach
CheckVendor -->|Yes| Probe[probeDevice]
Probe --> CheckDesc{sysfs report_descriptor<br/>has HID++ report ID 0x11?}
CheckDesc -->|No| Skip[Skip - wrong interface]
CheckDesc -->|Yes| Open[Open hidraw fd]
Open --> CheckPID{PID matches<br/>Bolt/Unifying receiver?}
CheckPID -->|Receiver| PingSlots[Ping slots 1-6]
PingSlots --> Found{Response from slot?}
Found -->|Yes| Connect[Store device + index]
Found -->|No| KeepOpen[Keep receiver open<br/>for DJ notifications]
CheckPID -->|Direct device| SetDirect[deviceIndex = 0xFF]
SetDirect --> Connect
Connect --> Enumerate[enumerateAndSetup]
Enumerate --> Features[FeatureDispatcher::enumerate]
Features --> ReadState[Read battery, DPI, SmartShift, scroll, thumb wheel, Easy-Switch]
ReadState --> LookupDesc[DeviceRegistry::findByPid/findByName]
LookupDesc --> Undivert[Undivert all buttons + thumb wheel]
Undivert --> CreateQueue[Create CommandProcessor]
CreateQueue --> Signal[emit deviceSetupComplete]
Before opening a hidraw device, Logitune checks the sysfs report descriptor for the HID++ long report ID (0x11). This is critical because:
- Each HID device exposes multiple hidraw interfaces (keyboard, mouse, vendor-specific)
- Opening and writing to the wrong interface can "poison" sibling interfaces
- The sysfs check at
/sys/class/hidraw/hidrawN/device/report_descriptoravoids this without opening the fd
For receiver connections, Logitune pings device indices 1-6 with a HID++ 2.0 Root feature request. The receiver may respond with:
- HID++ 2.0 long report (success)
- HID++ 1.0 short report (legacy device)
- HID++ 1.0 error with code 0x09 (no device on slot)
- HID++ 2.0 error (device not present)
If no device is found on any slot, the receiver fd is kept open and a QSocketNotifier watches for incoming traffic, indicating a device has connected.
DeviceRegistry loads device descriptors at startup from three sources
(scanned in order; earlier entries win on PID collisions via
findByPid returning the first match):
-
$XDG_DATA_DIRS/logitune/devices/<slug>/descriptor.json(system, wherecmake --installplaces thedevices/folder from the repo) -
$XDG_CACHE_HOME/logitune/devices/<slug>/descriptor.json(cache, rarely used directly) -
$XDG_DATA_HOME/logitune/devices/<slug>/descriptor.json(user override, for iterating on a community descriptor without rebuilding; remove the matching system descriptor first if you need the user version to take precedence)
Each descriptor is wrapped in a JsonDevice instance that exposes the
IDevice interface consumed by the rest of the app. JsonDevice is
the only concrete IDevice subclass; there are no per-device C++
classes. A new device is a descriptor.json file plus three images.
-
JsonDevice(src/core/devices/JsonDevice.{h,cpp}): parsesdescriptor.jsonand adapts to theIDeviceinterface. Tracks the source directory path and modification time for live reload support. -
DescriptorWriter(src/core/devices/DescriptorWriter.{h,cpp}): atomic writes todescriptor.json, preserving unknown fields so hand-edited entries survive a round-trip through the editor. -
EditorModel(src/app/models/EditorModel.{h,cpp}):--editmode state machine, undo/redo command stack, and file-conflict detection. Drives the in-app descriptor editor.
graph LR
EditorModel --> DescriptorWriter
DescriptorWriter --> JsonDevice
JsonDevice --> DeviceRegistry
For the contributor-facing workflow, see Adding a Device. For the visual-editing tool, see Editor Mode.
A device in Logitune is data, not code. Every supported mouse is a directory under devices/{slug}/ containing one descriptor.json plus front.png, side.png, and back.png. At startup DeviceRegistry enumerates these directories, wraps each one in a JsonDevice, and that JsonDevice * is what the rest of the app sees through the IDevice interface. Adding a new mouse requires no C++ changes.
IDevice (src/core/interfaces/IDevice.h) is the pure-virtual interface every descriptor satisfies. It is intentionally read-only: getters for identity (deviceName, productIds, matchesPid(pid)), DPI range (minDpi, maxDpi, dpiStep, dpiCycleRing), buttons (controls() returning QList<ControlDescriptor>), hotspots (buttonHotspots(), scrollHotspots()), feature support flags (features() returning FeatureSupport), images (frontImagePath, sideImagePath, backImagePath), default gestures (defaultGestures() keyed by "up" / "down" / "left" / "right" / "click"), and Easy-Switch slot positions (easySwitchSlotPositions()).
Three structs carry the runtime values:
-
ControlDescriptor: HID++controlId(e.g.0x00C3for the gesture button), zero-basedbuttonIndex, default name,defaultActionType("default","gesture-trigger","smartshift-toggle"), and aconfigurableflag that tells the UI whether the user can remap this button. -
HotspotDescriptor: per-button annotation coordinates for the QML overlay.xPct/yPctare 0 to 1 floats,sideis"front" / "side" / "back",kinddistinguishes scroll / thumb-wheel hotspots from button hotspots. -
FeatureSupport: 27 booleans gating UI visibility. WhensmartShiftisfalse, the SmartShift slider is hidden; whenthumbWheelisfalse, the Point & Scroll page hides thumb-wheel controls; and so on. See the MX Master 3S descriptor atdevices/mx-master-3s/descriptor.jsonfor the shape.
IDevice is consumed throughout the runtime: DeviceSession holds a const IDevice* as m_activeDevice, ProfileEngine seeds default.conf from it, ButtonModel iterates controls() to render the buttons list, ButtonActionDispatcher reads defaultGestures() for gesture routing, and the QML overlays read hotspots from DeviceModel.
JsonDevice (src/core/devices/JsonDevice.{h,cpp}) is the only concrete IDevice implementation shipped with Logitune. JsonDevice::load(dirPath) opens descriptor.json in the given directory, parses it into the member structures (m_pids, m_features, m_minDpi, m_controls, m_buttonHotspots, m_scrollHotspots, m_frontImage, m_sideImage, m_backImage, m_defaultGestures, m_easySwitchSlots, m_dpiCycleRing), and stores m_sourcePath + m_loadedMtime so DeviceRegistry::reload(path) can do targeted live reloads when the descriptor changes on disk. refreshFromObject(QJsonObject) lets EditorModel push pending in-memory edits into the live device without going through disk.
status() distinguishes Verified from Beta devices; the UI uses this to show a "beta descriptor" banner on first launch for community-contributed entries. There are no per-device C++ subclasses.
DescriptorWriter (src/core/devices/DescriptorWriter.{h,cpp}) is the save path for EditorModel. write(dirPath, QJsonObject, errorOut) uses QSaveFile for an atomic write (temp file + rename) to descriptor.json inside the target directory, returning Ok, IoError, or JsonError. Atomicity matters because the editor's QFileSystemWatcher would otherwise observe a truncated mid-write state and try to reload a broken descriptor. Not used outside editor mode.
DeviceFetcher (src/core/DeviceFetcher.{h,cpp}) brings community-contributed descriptors from GitHub into the user's local devices directory. fetchManifest() is called at startup (when isCacheFresh() returns false; the TTL is kCacheTtlSeconds = 3600), GETs kManifestUrl (the manifest.json in the logitune-devices GitHub repository), and compares each listed slug's manifestVersion against what is already cached. fetchForPid(pid) is wired to DeviceManager::unknownDeviceDetected(pid): when an unrecognized Logitech device appears, the fetcher looks up that PID in the cached manifest, downloads the matching descriptor plus images, and writes them into deviceCachePath(slug).
On successful fetch, DeviceFetcher emits descriptorsUpdated(), which DeviceRegistry subscribes to so it can rescan the local directory and expose the new device without a restart. The HTTP cache is keyed on ETag (saveEtag / loadEtag) and timestamp (saveTimestamp / isCacheFresh) to avoid re-downloading unchanged manifests.
Methods:
| Method | Purpose |
|---|---|
fetchManifest() |
Downloads manifest.json and kicks off targeted descriptor downloads for anything new or updated. |
fetchForPid(uint16_t pid) |
On-demand fetch path: finds the slug for pid in the cached manifest and downloads its descriptor plus images. |
setCacheDir(const QString &dir) |
Overrides the cache directory (used by tests). |
isCacheFresh() |
Returns true if the manifest timestamp is younger than kCacheTtlSeconds = 3600. |
saveTimestamp() |
Writes the current time to the timestamp file after a successful manifest fetch. |
saveEtag(const QString &etag) |
Persists the HTTP ETag from the manifest response for conditional requests. |
loadEtag() |
Returns the last-saved ETag, or an empty string. |
saveManifest(const QJsonObject &manifest) |
Persists the downloaded manifest JSON to disk. |
loadManifest() |
Returns the cached manifest as a QJsonObject. |
findDeviceForPid(const QJsonObject &manifest, uint16_t pid) |
Returns (slug, deviceInfo) for the first manifest entry that claims pid, or an empty pair. |
deviceNeedsUpdate(const QString &slug, int manifestVersion) |
Returns true if the cached descriptor for slug is older than manifestVersion. |
deviceCachePath(const QString &slug) |
Returns the on-disk cache path for the device slug. |
Signals:
| Signal | Emitted when |
|---|---|
descriptorsUpdated() |
Any fetch writes new descriptor files; wired in AppRoot::wireSignals to DeviceRegistry::reloadAll. |
When a device disconnects from a Bolt receiver (e.g., turned off, moved out of range), the receiver sends a HID++ 1.0 DeviceConnection notification (register 0x41):
sequenceDiagram
participant Dev as Device
participant Recv as Bolt Receiver
participant DMgr as DeviceManager
Dev->>Recv: (device powers off)
Recv->>DMgr: HID++ 1.0 notification<br/>featureIndex=0x41<br/>params[0] bit 6 = 1 (link not established)
Note over DMgr: Soft disconnect:<br/>- Clear CommandProcessor<br/>- Reset features<br/>- Keep hidraw fd open<br/>- Emit deviceDisconnected
Dev->>Recv: (device powers on)
Recv->>DMgr: HID++ 1.0 notification<br/>featureIndex=0x41<br/>params[0] bit 6 = 0 (link established)
Note over DMgr: Start 1500ms reconnect timer<br/>(debounce β device sends multiple<br/>notifications during boot)
DMgr->>DMgr: Timer fires: enumerateAndSetup()
Note over DMgr: Re-enumerate features,<br/>re-read state,<br/>re-create CommandProcessor,<br/>emit deviceSetupComplete
Key details:
- Soft disconnect β the hidraw fd stays open. Only logical state (features, command processor, connected flag) is reset.
- 1500ms debounce β the device sends multiple DJ notifications during boot, and HID++ calls fail with HwError if sent too early.
- Reconnect timer cancellation β if multiple link-established notifications arrive, only the last one triggers re-enumeration.
When a device is connected via both Bolt and Bluetooth:
- New hidraw device appears via udev "add" event
- DeviceManager pings the current device
- If the current device is unresponsive, switches to the new transport
- Emits
transportSwitched(newType)
checkSleepWake() monitors the gap between HID++ responses. If no response has been received for 2 minutes (kSleepThresholdMs = 120000), the device is assumed to have been sleeping. On the next response:
- Wait 500ms for the device to fully wake
- Re-enumerate features (firmware may have reset state)
- Emit
deviceWoke()
The touchResponseTime() method is called before intentional hardware writes to prevent false sleep/wake detection during profile switches.
The gesture system intercepts raw mouse XY deltas when the gesture button is held down:
stateDiagram-v2
[*] --> Idle
Idle --> GestureActive : Gesture button pressed<br/>(CID 0x00C3, diverted)
GestureActive --> GestureActive : Raw XY deltas<br/>accumulate dx, dy
GestureActive --> ResolveGesture : Button released<br/>(controlId = 0, all released)
ResolveGesture --> ExecuteAction : |dx| or |dy| > 50
ResolveGesture --> ExecuteClick : |dx| and |dy| <= 50
ExecuteAction --> Idle : Fire ButtonAction<br/>(Up/Down/Left/Right)
ExecuteClick --> Idle : Fire ButtonAction<br/>(Click)
Direction resolution:
- If
|dx| > |dy|: Left (dx < 0) or Right (dx > 0) - If
|dy| > |dx|: Up (dy < 0) or Down (dy > 0) - If neither exceeds threshold (50 units): Click
Per-direction ButtonAction is stored in Profile::gestures (a std::map<QString, ButtonAction> keyed by direction name). The fire path supports the same action types as button presses: keystroke, media, app-launch, DBus, and PresetRef (resolved via IDesktopIntegration::resolveNamedAction exactly like the button-press path - see PresetRef resolution).
The gesture button (CID 0x00C3 on MX Master 3S) is diverted with rawXY=true, which causes the device to send DivertedRawXYEvent notifications instead of normal mouse movement.
The thumb wheel supports four modes:
| Mode | HID++ | Action |
|---|---|---|
scroll |
Not diverted | Native horizontal scroll (no software processing) |
zoom |
Diverted | Ctrl+scroll injection (Ctrl held + vertical scroll event) |
volume |
Diverted | VolumeUp/VolumeDown key injection |
none |
Not diverted | No action |
When diverted, the device sends thumb wheel rotation events with raw delta values. These are:
-
Normalized by
thumbWheelDefaultDirection(read from ThumbWheel GetInfo) so clockwise = positive -
Accumulated in
m_thumbAccum -
Thresholded at
kThumbThreshold = 15to convert continuous rotation into discrete steps - Executed as the appropriate action for each step
The MX Master 3S reports defaultDirection = 0 (positive when left/back), so thumbWheelDefaultDirection = -1. Multiplying raw deltas by -1 makes clockwise = positive, which is the natural direction for zoom-in and volume-up.
ActionExecutor (src/core/ActionExecutor.{h,cpp}) is the bridge between ButtonActionDispatcher deciding "this button press should inject Ctrl+C" and an actual uinput / D-Bus / exec call reaching the system. It holds a non-owning IInputInjector* and delegates every side-effecting call to it, so swapping UinputInjector for MockInjector in tests is a one-line constructor change.
Responsibilities:
-
Action dispatch.
executeAction(const ButtonAction&)switches onaction.type:KeystrokeandMediaboth forward toinjectKeystroke(payload);DBustoexecuteDBusCall(payload);AppLaunchtolaunchApp(payload).Default,GestureTrigger, andSmartShiftToggleare handled upstream inButtonActionDispatcherand fall through to a no-op here.PresetRefis also resolved one layer up inButtonActionDispatcherbefore reaching this executor and is never seen here. -
Injector routing.
injectKeystroke(combo),injectCtrlScroll(direction),injectHorizontalScroll(direction),executeDBusCall(spec),launchApp(command)are one-line passthroughs to the injector. -
Gesture detection. Owns a
GestureDetectorinstance accessible viagestureDetector().GestureDetector::addDelta(dx, dy)accumulates the raw XY deltas emitted during a held gesture button;resolve()returns the dominant-axisGestureDirection(Up,Down,Left,Right, orClickwhen neither axis exceedskThreshold = 50).reset()is called between gestures.
Methods:
| Method | Purpose |
|---|---|
setInjector(IInputInjector *injector) |
Swaps the injector post-construction (used by AppRoot DI and test fixtures). |
executeAction(const ButtonAction &action) |
Switches on action.type and forwards to the appropriate injector call. Default, GestureTrigger, and SmartShiftToggle are handled upstream and no-op here. |
injectKeystroke(const QString &combo) |
Passthrough to IInputInjector::injectKeystroke. |
injectCtrlScroll(int direction) |
Passthrough to IInputInjector::injectCtrlScroll. |
injectHorizontalScroll(int direction) |
Passthrough to IInputInjector::injectHorizontalScroll. |
executeDBusCall(const QString &spec) |
Parses a service,path,interface,method spec and passes it to IInputInjector::sendDBusCall. |
launchApp(const QString &command) |
Passthrough to IInputInjector::launchApp. |
gestureDetector() |
Returns the owned GestureDetector & that accumulates raw XY deltas for gesture resolution. |
parseKeystroke(const QString &combo) |
Static: forwards to UinputInjector::parseKeystroke so tests can exercise the parser without opening /dev/uinput. |
parseDBusAction(const QString &spec) |
Static: splits a 4- or 5-field comma-separated spec (service,path,interface,method[,arg]) into a DBusCall struct (empty on malformed input). The optional 5th field is the method argument, used by kglobalaccel's invokeShortcut to pass the action name. |
gestureDirectionName(GestureDirection dir) |
Static: maps the enum to its display string. |
ActionExecutor stores no state of its own other than the gesture accumulator and the injector pointer.
Semantic action presets let the action catalog contain DE-independent entries like show-desktop or task-switcher. Profiles store the preset id; the active IDesktopIntegration resolves it to a concrete ButtonAction at fire time, so the action fires correctly regardless of the user's shortcut rebinds and profiles stay portable across desktop environments.
ActionPreset (src/core/actions/ActionPreset.{h,cpp}) is a POD: id, label, icon, category, and a map variantKey -> QJsonObject of DE-native hints. The hint shape is not interpreted here; each DE impl owns the parse logic for its own variant kind (kglobalaccel, gsettings, app-launch, etc). fromJson(QJsonObject) returns an invalid preset (empty id) on malformed input.
ActionPresetRegistry (src/core/actions/ActionPresetRegistry.{h,cpp}) owns the catalog. It loads the bundled Qt resource at :/logitune/actions.json via loadFromResource() and indexes by id.
Methods:
| Method | Purpose |
|---|---|
loadFromJson(const QByteArray &json) |
Parse JSON (expected array of presets), replace contents. Returns count of successfully-loaded presets. Malformed entries are skipped. |
loadFromResource() |
Read :/logitune/actions.json and call loadFromJson. Logs on missing resource and on parse failure. |
preset(const QString &id) |
Lookup by id. Returns nullptr for unknown ids. Pointer is valid until the next load call. |
supportedBy(const QString &id, const QString &variantKey) |
True if the preset exists AND has a variant for that key. Used by ActionFilterModel to filter the picker. |
variantData(const QString &id, const QString &variantKey) |
Returns the hint object for that id/variant pair, or empty object if either is absent. Used by DE impls. |
all() |
Const reference to the insertion-ordered vector. Used by UI listings. |
Each IDesktopIntegration impl overrides two virtuals:
-
variantKey()returns the short string key used inactions.json("kde","gnome","hyprland","generic"). -
resolveNamedAction(id)returnsstd::optional<ButtonAction>- the concrete action the executor can fire, or nullopt if this DE cannot resolve the preset.
KDE (KDeDesktop::resolveNamedAction) reads the kde variant from the registry. For a kglobalaccel hint ({"component": "...", "name": "..."}), it emits a DBus ButtonAction with a 5-field payload: org.kde.kglobalaccel,/component/<component>,org.kde.kglobalaccel.Component,invokeShortcut,<name>. kglobalaccel then invokes the action by name, independent of what keystroke the user has bound to it. For app-launch hints, it returns an AppLaunch ButtonAction directly.
GNOME (GnomeDesktop::resolveNamedAction) reads the gnome variant. For a gsettings hint ({"schema": "...", "key": "..."}), it shells out to gsettings get <schema> <key> via QProcess, parses the GLib variant output (e.g. ['<Super>d']), and rewrites the modifier tokens into Logitune keystroke format (<Primary> -> Ctrl, <Super>/<Meta>/<Hyper> -> Super, <AltGr>/<ISO_Level3_Shift> dropped). Returns a Keystroke ButtonAction with the resolved combo. Empty binding (user cleared the shortcut) returns nullopt so the picker can grey out the preset. For app-launch hints, direct passthrough.
Hyprland (HyprlandDesktop::resolveNamedAction) reads the hyprland variant. For a hyprland-bind hint ({"dispatcher": "...", "arg": "..."}), it queries Hyprland's live bind list through j/binds, finds a matching default-submap keyboard bind, converts supported modifiers (Ctrl, Shift, Alt, Super) and keys into Logitune keystroke format, and returns a Keystroke ButtonAction. Missing binds, mouse binds, unsupported modifier masks, or unsupported key names return nullopt. For app-launch hints, direct passthrough.
Generic returns nullopt for every id. No preset has a "generic" variant in the shipped catalog.
ActionFilterModel applies two gates to rows with actionType == "preset":
-
registry->supportedBy(id, desktop->variantKey())- hides presets with no variant for the active DE. -
desktop->resolveNamedAction(id).has_value()- hides presets whose live binding is empty or otherwise unresolvable at filter time (primarily a GNOME case).
Capability-based filtering (dpi-cycle, smartshift-toggle, gesture-trigger, wheel-mode) is unchanged.
AppRoot registers two ActionFilterModel instances as QML singletons: ActionModel for the regular per-button picker, and GestureActionModel (constructed with setGestureMode(true)) for the gesture sub-direction picker. The gesture-mode filter additionally hides actions whose runtime path the gesture-release dispatcher can't sensibly fire as a sub-direction (gesture-trigger is recursive; none is already covered by the picker's "None" sentinel). Other types pass through.
AppRoot owns an ActionPresetRegistry member, calls loadFromResource() at construction, and injects it into the concrete DE impls via dynamic_cast. The registry is also passed to both ActionFilterModel instances. New DE impls that want to resolve presets override variantKey() + resolveNamedAction() and accept the registry via setPresetRegistry().
The catalog lives at src/core/actions/actions.json and is bundled as a Qt resource (qt_add_resources in src/core/CMakeLists.txt). Each entry:
{
"id": "show-desktop",
"label": "Show desktop",
"icon": "desktop",
"category": "workspace",
"variants": {
"kde": { "kglobalaccel": { "component": "kwin", "name": "Show Desktop" } },
"gnome": { "gsettings": { "schema": "org.gnome.desktop.wm.keybindings", "key": "show-desktop" } }
}
}Adding a new preset is a catalog edit plus (if needed) a new variant kind handled in the DE impls.
Behavior that responds to user events or mutates application state lives in one of four focused services in src/app/services/. Each service holds non-owning pointers to the models, engines, or ActiveDeviceResolver instance it needs, has zero connect() calls of its own, and communicates with its peers only via Qt signals wired by AppRoot.
The dependency rule, enforced at the code level:
Services hold pointers only to models, engines, and
ActiveDeviceResolver. Cross-service communication is always via signal, wired inAppRoot.
Resolves the currently selected PhysicalDevice / DeviceSession / serial from DeviceModel's selection index and its ordered device list. This is the single source of truth for "who is selected", so every other service asks here rather than re-deriving it.
Constructor dependencies: DeviceModel*. State: none (pure projection over DeviceModel).
Methods:
| Method | Purpose |
|---|---|
activeDevice() |
Returns the currently selected PhysicalDevice*, or nullptr if none. |
activeSession() |
Returns the primary DeviceSession* of the active device. |
activeSerial() |
Returns the HID++ serial of the active device as a QString. |
Signals:
| Signal | Emitted when |
|---|---|
selectionChanged() |
The resolved active device or session changes. |
Slots:
| Slot | Responds to |
|---|---|
onSelectionIndexChanged() |
DeviceModel::selectedChanged, wired in AppRoot::wireSignals. |
Routes UI change requests (slider drag, toggle click) to the active DeviceSession. Every mutator is a no-op when there is no active session, which makes the service safe to invoke before any device attaches.
Constructor dependencies: ActiveDeviceResolver*. State: none.
Signals:
| Signal | Emitted when |
|---|---|
userChangedSomething() |
After every successful mutation, for ProfileOrchestrator::saveCurrentProfile to latch on to. |
Slots:
| Slot | Responds to |
|---|---|
requestDpi(int value) |
DeviceModel::dpiChangeRequested, wired in AppRoot::wireSignals (hardware-guarded lambda). |
requestSmartShift(bool enabled, int threshold) |
DeviceModel::smartShiftChangeRequested, wired in AppRoot::wireSignals. |
requestScrollConfig(bool hiRes, bool invert) |
DeviceModel::scrollConfigChangeRequested, wired in AppRoot::wireSignals. |
requestThumbWheelMode(const QString &mode) |
DeviceModel::thumbWheelModeChangeRequested, wired in AppRoot::wireSignals. |
requestThumbWheelInvert(bool invert) |
DeviceModel::thumbWheelInvertChangeRequested, wired in AppRoot::wireSignals. |
Note: applyProfileToHardware (the burst during a profile switch) does not go through DeviceCommandHandler. It calls the session directly. DeviceCommandHandler is specifically for user-initiated control changes from the UI.
Interprets raw HID++ input events (gestureRawXY, divertedButtonPressed, thumbWheelRotation) and dispatches high-level actions: keystroke injection, app launch, DPI cycle, SmartShift toggle, gesture direction resolution, and thumb-wheel-mode actions. Owns the per-device gesture + thumb-wheel accumulator state.
Constructor dependencies: ProfileEngine*, ActionExecutor*, ActiveDeviceResolver*, IDesktopIntegration*. State: QMap<QString, PerDeviceState> keyed by serial. Each entry holds gestureAccumX/Y, thumbAccum, gestureActive, gestureControlId. Gesture threshold is 50, thumb threshold is 15. The desktop pointer is used to resolve PresetRef actions at fire time on both the button-press and gesture-release paths.
Methods:
| Method | Purpose |
|---|---|
onDeviceRemoved(const QString &serial) |
Drops the PerDeviceState entry for a departing device. Called from AppRoot::onPhysicalDeviceRemoved. |
Slots:
| Slot | Responds to |
|---|---|
onGestureRaw(int16_t dx, int16_t dy) |
PhysicalDevice::gestureRawXY, wired per-device in AppRoot::onPhysicalDeviceAdded. |
onDivertedButtonPressed(uint16_t controlId, bool pressed) |
PhysicalDevice::divertedButtonPressed, wired per-device in AppRoot::onPhysicalDeviceAdded. |
onThumbWheelRotation(int delta) |
PhysicalDevice::thumbWheelRotation, wired per-device in AppRoot::onPhysicalDeviceAdded. |
onProfileApplied(const QString &serial) |
ProfileOrchestrator::profileApplied, used to reset thumbAccum for the given serial. |
onCurrentDeviceChanged(const IDevice *device) |
ProfileOrchestrator::currentDeviceChanged, keeps the local IDevice* pointer in sync. |
When a button's action is a ButtonAction::PresetRef, the dispatcher looks up the preset id on the active IDesktopIntegration. desktop->resolveNamedAction(id) returns an std::optional<ButtonAction> that resolves the semantic id (e.g. show-desktop) to a concrete Keystroke / DBus / AppLaunch payload using the per-DE strategy (KDE: kglobalaccel DBus by name; GNOME: gsettings lookup plus keystroke inject; Generic: nullopt). The resolved action is then handed to ActionExecutor::executeAction, so the executor never sees a PresetRef directly. If the desktop is null or resolution returns nullopt (user cleared the GNOME binding, or the DE lacks a variant for this preset), the dispatcher logs a warning and fires nothing.
The same resolution path runs for gestures: when a gesture-release fires, the Profile::gestures map yields a full ButtonAction (gestures store the same enum the button-press path uses, not just keystroke strings). If the action is a PresetRef, it goes through desktop->resolveNamedAction(id) exactly like a button press, then the resolved action is handed to ActionExecutor::executeAction. This is why the dispatcher takes the IDesktopIntegration* in its constructor.
Owns the save, apply, push, and window-focus flow. Holds no device state beyond a current IDevice* pointer; reads from models and engines, writes to them, and emits profileApplied(serial) after every hardware apply so the dispatcher can reset its thumb accumulator.
Constructor dependencies: ProfileEngine*, ActionExecutor*, ActiveDeviceResolver*, DeviceModel*, ButtonModel*, ActionModel*, ProfileModel*, IDesktopIntegration*. State: m_currentDevice (non-owning const IDevice*).
Methods:
| Method | Purpose |
|---|---|
setupProfileForDevice(PhysicalDevice *device) |
Initializes profile context for a newly-attached device (registers serial, seeds default.conf if missing). |
applyProfileToHardware(const Profile &p) |
Enqueues DPI, SmartShift, scroll, thumb-wheel, and button-divert commands on the active session for the given profile. |
applyDisplayedChange<Mutator, HardwareForward>(Mutator mutator, HardwareForward hardwareForward) |
Templated bridge for the five DeviceModel::*ChangeRequested signals: mutates the displayed profile's cached copy, persists it, refreshes the UI, and forwards to hardware only when the displayed profile is also the active one. |
Signals:
| Signal | Emitted when |
|---|---|
profileApplied(const QString &serial) |
applyProfileToHardware finishes enqueuing all commands for serial. |
currentDeviceChanged(const IDevice *device) |
m_currentDevice is updated (device selection change or descriptor reload). |
Slots:
| Slot | Responds to |
|---|---|
saveCurrentProfile() |
DeviceCommandHandler::userChangedSomething and DeviceModel::userGestureChanged, wired in AppRoot::wireSignals. |
onUserButtonChanged(int buttonId, const QString &actionName, const QString &actionType) |
ButtonModel::userActionChanged, wired in AppRoot::wireSignals. |
onTabSwitched(const QString &profileName) |
ProfileModel::profileSwitched, wired in AppRoot::wireSignals. |
onDisplayProfileChanged(const QString &serial, const Profile &profile) |
ProfileEngine::deviceDisplayProfileChanged, wired in AppRoot::wireSignals. |
onWindowFocusChanged(const QString &wmClass, const QString &title) |
IDesktopIntegration::activeWindowChanged, wired in AppRoot::wireSignals. |
onTransportSetupComplete(PhysicalDevice *device) |
PhysicalDevice::transportSetupComplete, wired per-device in AppRoot::onPhysicalDeviceAdded. |
onCurrentDeviceChanged(const IDevice *device) |
ActiveDeviceResolver::selectionChanged via AppRoot::onSelectionChanged. |
AppRoot is the composition root. It owns the long-lived singletons (ViewModels, services, engines, DeviceRegistry, DeviceManager, DeviceFetcher), wires the signal graph between them at startup, attaches PhysicalDevice instances into the graph at runtime, and exposes ViewModels via accessors for QML registration in main.cpp. It does not implement user-facing behavior; every connect() call in the app library lives either in wireSignals() or in onPhysicalDeviceAdded().
The wiring falls into three groups:
-
(a) QML β AppRoot ViewModel β Service. User events from QML update a ViewModel;
AppRoothas aconnect()from the ViewModel's signal into the relevant service slot. -
(b) Service β Service. Cross-service communication, always via a signal emitted by one service and a slot on another, with the edge living in
wireSignals(). -
(c) Hardware β AppRoot β Service. Per-device runtime wiring in
onPhysicalDeviceAdded: each newPhysicalDevicegets its input signals (gestureRawXY,divertedButtonPressed,thumbWheelRotation,transportSetupComplete) hooked into the appropriate service slots.
Scope of this table: the connect() calls AppRoot performs. Not every signal in the app.
AppRoot wires the signal graph between services, ViewModels, engines, and interfaces at startup (inside wireSignals() and startMonitoring()), plus the per-device runtime wiring that fires when a PhysicalDevice is attached (inside onPhysicalDeviceAdded()). Other subsystems wire their own internal listeners:
-
PhysicalDevicewires its attachedDeviceSessiontransports as they come and go (dynamic fan-in, not startable from AppRoot) -
TrayManagerwires its ownDeviceModel::countChangedandPhysicalDevice::stateChangedsubscriptions inside its constructor (it is a UI shell, not one of the four services subject to the "noconnect()in services" rule) -
DeviceModelwires internal property cascades between its own members -
DeviceFetcher,KDeDesktop,GnomeDesktop,DeviceSession,CommandProcessor,EditorModelwire their own Qt framework plumbing:QNetworkReply, D-Bus proxies,QTimer,QFileSystemWatcher,QSocketNotifier. These are implementation details of each class.
The table below covers only AppRoot's wiring. Each row is one connect() call. Where one signal lands in a lambda that has two distinct effects (the *ChangeRequested fan-outs), each effect gets its own row so both the cache-update path and the hardware-forward path are visible.
Most signals have only one sink because AppRoot's graph is deliberately linear. Qt supports one-to-many emission; AppRoot simply rarely needs it. The one AppRoot-level exception is DeviceModel::selectedChanged, which has two sinks: ActiveDeviceResolver at startup (always) and an EditorModel path in startMonitoring (editor mode only).
Groups (ordering the rows below):
-
π΅ Startup β
connect()calls inwireSignals()orstartMonitoring()where the source is a ViewModel, engine, manager, or interface. These are the "ordinary" wires: a user event or domain event in, a service slot out. -
π£ Cross-service β also wired at startup. Source is one of the four services or
ActiveDeviceResolver; sink is another service or AppRoot itself. These are the signal paths that make the service mesh cooperate (e.g.ProfileOrchestrator::profileAppliedβButtonActionDispatcher::onProfileApplied). -
π Per-device β runtime wiring made in
onPhysicalDeviceAdded(). These fire every time a newPhysicalDeviceattaches and tear down on detach. The source is always aPhysicalDeviceinstance.
How to decide the group for a new row:
- Is the
connect()insideonPhysicalDeviceAdded()or a method it calls? β π Per-device. - Else: is the source one of the four services or
ActiveDeviceResolver? β π£ Cross-service. - Else (source is a ViewModel / engine / manager / interface, and the wire is made at startup): β π΅ Startup.
| Source | Signal | Sink |
|---|---|---|
| ButtonModel | userActionChanged |
ProfileOrchestrator::onUserButtonChanged
|
| IDesktopIntegration | activeWindowChanged |
ProfileOrchestrator::onWindowFocusChanged
|
| ProfileModel | profileSwitched |
ProfileOrchestrator::onTabSwitched
|
| ProfileEngine | deviceDisplayProfileChanged |
ProfileOrchestrator::onDisplayProfileChanged
|
| DeviceModel | selectedChanged |
ActiveDeviceResolver::onSelectionIndexChanged
|
| DeviceModel | selectedChanged |
lambda β EditorModel::setActiveDevicePath (editor mode only) |
| DeviceModel | userGestureChanged |
lambda β ProfileOrchestrator::saveCurrentProfile
|
| DeviceModel | dpiChangeRequested |
lambda β ProfileOrchestrator::applyDisplayedChange (cache + UI) |
| DeviceModel | dpiChangeRequested |
lambda β DeviceCommandHandler::requestDpi (hardware, guarded) |
| DeviceModel | smartShiftChangeRequested |
lambda β ProfileOrchestrator::applyDisplayedChange (cache + UI) |
| DeviceModel | smartShiftChangeRequested |
lambda β DeviceCommandHandler::requestSmartShift (hardware, guarded) |
| DeviceModel | scrollConfigChangeRequested |
lambda β ProfileOrchestrator::applyDisplayedChange (cache + UI) |
| DeviceModel | scrollConfigChangeRequested |
lambda β DeviceCommandHandler::requestScrollConfig (hardware, guarded) |
| DeviceModel | thumbWheelModeChangeRequested |
lambda β ProfileOrchestrator::applyDisplayedChange (cache + UI) |
| DeviceModel | thumbWheelModeChangeRequested |
lambda β DeviceCommandHandler::requestThumbWheelMode (hardware, guarded) |
| DeviceModel | thumbWheelInvertChangeRequested |
lambda β ProfileOrchestrator::applyDisplayedChange (cache + UI) |
| DeviceModel | thumbWheelInvertChangeRequested |
lambda β DeviceCommandHandler::requestThumbWheelInvert (hardware, guarded) |
| ProfileModel | profileAdded |
lambda β ProfileEngine::createProfileForApp
|
| ProfileModel | profileRemoved |
lambda β ProfileEngine::removeAppProfile
|
| DeviceManager | physicalDeviceAdded |
AppRoot::onPhysicalDeviceAdded
|
| DeviceManager | physicalDeviceRemoved |
AppRoot::onPhysicalDeviceRemoved
|
| DeviceManager | unknownDeviceDetected |
DeviceFetcher::fetchForPid
|
| DeviceFetcher | descriptorsUpdated |
lambda β DeviceRegistry::reloadAll
|
The lambdas in the DeviceModel::*ChangeRequested rows all call applyDisplayedChange(mutator, hardwareForward). That helper persists the cached profile, refreshes the UI unconditionally, and only invokes DeviceCommandHandler when the displayed profile is also the active hardware profile. The DeviceManager::unknownDeviceDetected / DeviceFetcher::descriptorsUpdated pair is the device-database integration path: an unknown PID triggers a targeted fetch, and a successful fetch reloads the local descriptor registry so the new device appears on the next enumeration without a restart.
| Source | Signal | Sink |
|---|---|---|
| ActiveDeviceResolver | selectionChanged |
AppRoot::onSelectionChanged
|
| DeviceCommandHandler | userChangedSomething |
ProfileOrchestrator::saveCurrentProfile
|
| ProfileOrchestrator | profileApplied |
ButtonActionDispatcher::onProfileApplied
|
| ProfileOrchestrator | currentDeviceChanged |
ButtonActionDispatcher::onCurrentDeviceChanged
|
| Source | Signal | Sink |
|---|---|---|
| PhysicalDevice | gestureRawXY |
ButtonActionDispatcher::onGestureRaw
|
| PhysicalDevice | divertedButtonPressed |
ButtonActionDispatcher::onDivertedButtonPressed
|
| PhysicalDevice | thumbWheelRotation |
ButtonActionDispatcher::onThumbWheelRotation
|
| PhysicalDevice | transportSetupComplete |
lambda β ProfileOrchestrator::onTransportSetupComplete
|
AppRoot accepts optional IDesktopIntegration* and IInputInjector* in its constructor:
AppRoot(IDesktopIntegration *desktop, IInputInjector *injector, QObject *parent = nullptr);
- If
nullptris passed (production), it createsKDeDesktopandUinputInjectorinternally - In tests,
MockDesktopandMockInjectorare injected for deterministic behavior - The injected pointers are not owned by AppRoot (raw pointers); internally created ones are held in
unique_ptr
This is the sole DI point: the rest of the subsystems are value members of AppRoot, which simplifies lifetime management.
TrayManager (src/app/TrayManager.{h,cpp}) owns the QSystemTrayIcon and its context menu. It is constructed with a non-owning DeviceModel* so it can reflect per-device state (name, battery) without reaching into DeviceManager directly.
Menu composition. The menu skeleton is fixed: Show Logitune at the top, a separator, then a block of per-device entries inserted before a trailing Quit. Each device contributes a DeviceEntry struct (TrayManager.h lines 32 to 37) with three disabled actions: a header showing deviceName(), a battery row showing "Battery: N%", and a trailing separator. rebuildEntries() reconciles the action list against DeviceModel::devices() on every countChanged, deleting entries for removed devices and inserting entries for new ones while leaving existing ones in place.
Per-device wiring. refreshEntry(PhysicalDevice*) updates the header and battery labels and is reconnected via stateConn (QMetaObject::Connection stored per entry) when the device emits stateChanged. This keeps the battery indicator live without the tray having to poll.
User interaction. The Show Logitune action and a left click on the icon (QSystemTrayIcon::activated with reason Trigger) both emit showWindowRequested(); AppRoot wires this to the main window's show + raise + activate sequence. The Quit action is hooked up by the caller to QApplication::quit (it is exposed via quitAction() rather than wired internally so tests can suppress it).
Tooltip. refreshTooltip() populates the hover tooltip when no devices are attached (falls back to an app-level string) and when the device list changes.
TrayManager does not own the DeviceModel or the PhysicalDevice objects in m_entries; lifetimes are guarded by the dtor disconnecting every stateConn before destruction.
Methods:
| Method | Purpose |
|---|---|
trayIcon() |
Returns the owned QSystemTrayIcon * (accessor used by AppRoot for tooltip and icon management). |
menu() |
Returns the owned QMenu *. |
showAction() |
Returns the Show Logitune QAction *. |
quitAction() |
Returns the Quit QAction *, so the caller can wire it to QApplication::quit (kept external to simplify test suppression). |
show() |
Installs the tray icon and makes it visible. |
Signals:
| Signal | Emitted when |
|---|---|
showWindowRequested() |
User triggers Show Logitune or left-clicks the tray icon; AppRoot wires this to the main window's show + raise + activate sequence. |