Skip to content

Architecture

Mina Maher edited this page Apr 1, 2026 · 12 revisions

πŸ—οΈ 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.

πŸ—ΊοΈ System Overview

Buttons page with device render and hotspot callouts
Buttons page β€” one of four sidebar-navigated views, showing the device render and hotspot callouts

graph TB
    subgraph "QML UI"
        Main[Main.qml]
        Pages[Pages: PointScroll, Buttons, EasySwitch, Settings]
        Components[Components: DeviceRender, SideNav, ProfileBar]
    end

    subgraph "App Layer (logitune-app-lib)"
        AC[AppController]
        DM_model[DeviceModel]
        BM[ButtonModel]
        AM[ActionModel]
        PM[ProfileModel]
        TM[TrayManager]
    end

    subgraph "Core Layer (logitune-core)"
        DevMgr[DeviceManager]
        PE[ProfileEngine]
        AE[ActionExecutor]
        DR[DeviceRegistry]
        
        subgraph "HID++ Protocol"
            FD[FeatureDispatcher]
            CQ[CommandQueue]
            TR[Transport]
            HR[HidrawDevice]
        end

        subgraph "Desktop Integration"
            IDesktop[IDesktopIntegration]
            KDE[KDeDesktop]
            Generic[GenericDesktop]
        end

        subgraph "Input Injection"
            IInject[IInputInjector]
            Uinput[UinputInjector]
        end
    end

    subgraph "System"
        hidraw[/dev/hidrawN/]
        udev[libudev]
        dbus[D-Bus Session Bus]
        uinputdev[/dev/uinput/]
    end

    Main --> DM_model
    Main --> BM
    Main --> AM
    Main --> PM
    Pages --> DM_model
    Pages --> BM

    AC --> DM_model
    AC --> BM
    AC --> AM
    AC --> PM
    AC --> DevMgr
    AC --> PE
    AC --> AE
    AC --> IDesktop

    DevMgr --> DR
    DevMgr --> FD
    DevMgr --> CQ
    DevMgr --> TR
    DevMgr --> udev

    FD --> TR
    CQ --> FD
    TR --> HR
    HR --> hidraw

    KDE --> dbus
    IDesktop -.-> KDE
    IDesktop -.-> Generic

    AE --> IInject
    IInject -.-> Uinput
    Uinput --> uinputdev

    TM --> DM_model
Loading

πŸ“¦ Two Static Libraries

The project is split into two static libraries:

Library Contents Dependencies
πŸ”§ logitune-core DeviceManager, HID++ protocol, ProfileEngine, ActionExecutor, device descriptors, desktop integration, input injection, logging Qt6::Core, Qt6::DBus, libudev
🎨 logitune-app-lib AppController, models (DeviceModel, ButtonModel, ActionModel, ProfileModel), TrayManager, QML module, dialogs logitune-core, Qt6::Quick, Qt6::Widgets

Tip

This split allows tests to link against logitune-core and logitune-app-lib without pulling in the executable's main(). See Testing for details.

⚑ Signal Flow

πŸ”„ Window Focus Change -> Profile Switch -> Hardware Commands

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 AC as AppController
    participant PE as ProfileEngine
    participant PM as ProfileModel
    participant DM as DeviceModel
    participant DMgr as DeviceManager
    participant CQ as CommandQueue
    participant FD as FeatureDispatcher
    participant TR as Transport

    KWin->>KDE: callDBus focusChanged(resourceClass, title, desktopFileName)
    KDE->>KDE: resolveDesktopFile(resourceClass)
    KDE->>AC: activeWindowChanged(appId, title)
    
    Note over AC: Skip shell components (plasmashell, krunner)
    
    AC->>DM: setActiveWmClass(wmClass)
    AC->>PE: profileForApp(wmClass)
    PE-->>AC: profileName (or "default")
    
    Note over AC: Skip if same as current hardware profile
    
    AC->>PE: cachedProfile(profileName)
    AC->>PE: setHardwareProfile(profileName)
    AC->>AC: applyProfileToHardware(profile)
    
    par Apply all settings
        AC->>DMgr: divertButton(CID, divert, rawXY) [for each button]
        AC->>DMgr: setDPI(value)
        AC->>DMgr: setSmartShift(enabled, threshold)
        AC->>DMgr: setScrollConfig(hiRes, invert)
        AC->>DMgr: setThumbWheelMode(mode, invert)
    end
    
    Note over DMgr: Each setter enqueues via CommandQueue
    
    loop For each enqueued command
        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
    
    AC->>PM: setHwActiveByProfileName(profileName)
Loading

Point & Scroll with scroll wheel detail panel open
Point & Scroll β€” the detail panel that opens when writing individual scroll settings through the signal flow above

🎯 Key Design Decision: Display vs Hardware Profile

Important

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.

πŸ”Œ HID++ Protocol Layer

Note

For the full protocol deep dive, see the dedicated HID++ Protocol page.

πŸ“š Stack

graph TB
    subgraph "Application"
        DevMgr[DeviceManager]
    end

    subgraph "Protocol"
        CQ[CommandQueue<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
Loading

πŸ” Feature Discovery

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
Loading

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
πŸ”‹ BatteryUnified 0x1004 Battery level and charging status
πŸ”Œ ChangeHost 0x1814 Easy-Switch host info
πŸ”€ ReprogControlsV4 0x1b04 Button diversion and remapping
βš™οΈ SmartShift 0x2110 SmartShift ratchet/freespin control
πŸ“œ 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)

πŸ“¨ Command Queue

The CommandQueue exists to solve a specific problem: HwError flooding.

Warning

When a profile switch happens, Logitune needs to send many HID++ commands in rapid succession (~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 CommandQueue
    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()
Loading

Key properties:

Property Value Details
⏱️ Inter-command delay 10ms (kInterCommandDelayMs = 10) Enough for the device to process each command
πŸ” Retries 3 (kMaxRetries = 3) With 50ms retry delay
🧡 Threading Main thread only Uses QTimer, no mutex needed, no fd contention with QSocketNotifier
πŸ“ Creation After feature enumeration Instantiated inside enumerateAndSetup() after the feature table is populated

πŸ”— Async Response Matching

FeatureDispatcher::callAsync() uses a rotating softwareId (1-15) to match responses to requests:

sequenceDiagram
    participant CQ as CommandQueue
    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
Loading

Caution

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

Without this, async responses from thumb wheel SetReporting were being misinterpreted as thumb wheel rotation events (the "delta=256 bug").

πŸ“‚ Profile System

πŸ—„οΈ Profile Struct

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, 8> buttons;   // indexed 0-7
    std::map<QString, ButtonAction> gestures;  // "up","down","left","right","click"
    QString thumbWheelMode = "scroll";  // "scroll", "zoom", "volume", "none"
    bool thumbWheelInvert = false;
};

πŸ”„ ProfileEngine

graph TB
    subgraph "ProfileEngine"
        Cache["In-Memory Cache<br/>QMap&lt;QString, Profile&gt;"]
        Disk["Disk Storage<br/>~/.config/Logitune/devices/&lt;serial&gt;/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
Loading

πŸ”„ Profile Lifecycle

  1. πŸ”Œ Device connects β€” onDeviceSetupComplete() creates the profile directory under ~/.config/Logitune/devices/<serial>/profiles/
  2. 🌱 First connect β€” seeds default.conf from current device hardware state (DPI, SmartShift, scroll config, button defaults from descriptor, default gestures)
  3. πŸ“‚ Profile load β€” setDeviceConfigDir() scans the directory for .conf files and loads them into the in-memory cache
  4. πŸ” Focus change β€” profileForApp(wmClass) looks up the app binding; if none found, returns "default"
  5. ⚑ Hardware apply β€” applyProfileToHardware() sends all profile settings via CommandQueue
  6. ✏️ User edit β€” UI changes go through DeviceModel -> AppController -> ProfileEngine cache -> disk save
  7. πŸ’Ύ Cache vs disk β€” the cache is the source of truth during runtime; saves to disk are immediate but loads only happen at startup

πŸ“Š ProfileDelta

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;
};

Tip

This enables future optimizations where only changed settings are sent to hardware during profile switches.

🎨 MVVM Pattern

Logitune uses a Model-View-ViewModel pattern where C++ models serve as the ViewModel layer between QML views and core logic.

Buttons page with Actions panel open
Buttons page β€” the Actions panel (ActionModel) bound to ButtonsPage.qml via ButtonModel, illustrating the MVVM binding

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<br/>QObject singleton]
        BM[ButtonModel<br/>QAbstractListModel]
        AM[ActionModel<br/>QAbstractListModel]
        PM[ProfileModel<br/>QAbstractListModel]
    end

    subgraph "Model (Core)"
        DMgr[DeviceManager]
        PE[ProfileEngine]
        AE[ActionExecutor]
    end

    PointScroll --> DM
    Buttons --> BM
    Buttons --> AM
    Buttons --> DM
    EasySwitch --> DM
    Settings --> DM
    ProfileBar --> PM

    DM --> DMgr
    BM --> AC[AppController]
    AM --> AC
    PM --> AC
    AC --> DMgr
    AC --> PE
    AC --> AE
Loading

πŸ“‹ Model Roles

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", etc.
PayloadRole QString Keystroke combo or app command

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

πŸ”§ Model Registration

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.actionModel());
qmlRegisterSingletonInstance("Logitune", 1, 0, "ProfileModel", controller.profileModel());

πŸ–₯️ Desktop Integration

πŸ“ Interface Hierarchy

classDiagram
    class IDesktopIntegration {
        <<abstract>>
        +start()
        +available() bool
        +desktopName() QString
        +detectedCompositors() QStringList
        +blockGlobalShortcuts(bool block)
        +runningApplications() QVariantList
        +activeWindowChanged(wmClass, title) signal
    }

    class KDeDesktop {
        +focusChanged(resourceClass, title, desktopFileName)
        -resolveDesktopFile(resourceClass) QString
        -m_kwin : QDBusInterface
        -m_pollTimer : QTimer
        -m_resolveCache : QHash
    }

    class GenericDesktop {
        +start()
        +available() bool
    }

    IDesktopIntegration <|-- KDeDesktop
    IDesktopIntegration <|-- GenericDesktop
Loading

Note

For a complete guide on adding new desktop environment support, see Adding a Desktop Environment.

πŸ” KDE Focus Tracking

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 AppController

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

πŸͺͺ Window Identity Resolution

Warning

A critical problem: the same application can have different identifiers depending on how it's packaged:

  • Zoom: resourceClass="zoom", but .desktop file is us.zoom.Zoom.desktop
  • Firefox Flatpak: desktopFileName="org.mozilla.firefox"
  • Native KDE apps: desktopFileName="org.kde.dolphin"

resolveDesktopFile() searches these directories:

  1. /usr/share/applications
  2. /run/host/usr/share/applications (host apps inside Flatpak)
  3. ~/.local/share/applications
  4. /var/lib/flatpak/exports/share/applications
  5. ~/.local/share/flatpak/exports/share/applications
  6. /var/lib/snapd/desktop/applications

It matches by:

  1. Last component of the .desktop filename (e.g., "Zoom" from "us.zoom.Zoom")
  2. StartupWMClass field in the .desktop file

Results are cached in m_resolveCache to avoid repeated filesystem scans.

πŸ”’ blockGlobalShortcuts

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

Tip

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.

πŸ” Device Discovery and Connection

πŸ”Ž Discovery Flow

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 CommandQueue]
    CreateQueue --> Signal[emit deviceSetupComplete]
Loading

πŸ“„ Report Descriptor Check

Important

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_descriptor avoids this without opening the fd

πŸ“‘ Bolt Receiver Slot Probing

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.

πŸ”„ Disconnect and Reconnect

πŸ“‘ Bolt Receiver DJ Notifications

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 CommandQueue<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 CommandQueue,<br/>emit deviceSetupComplete
Loading

Key details:

Aspect Behavior
πŸ”— Soft disconnect The hidraw fd stays open. Only logical state (features, command queue, 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.

πŸ”ƒ Transport Failover

When a device is connected via both Bolt and Bluetooth:

  1. New hidraw device appears via udev "add" event
  2. DeviceManager pings the current device
  3. If the current device is unresponsive, switches to the new transport
  4. Emits transportSwitched(newType)

πŸ’€ Sleep/Wake Detection

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:

  1. Wait 500ms for the device to fully wake
  2. Re-enumerate features (firmware may have reset state)
  3. Emit deviceWoke()

Note

The touchResponseTime() method is called before intentional hardware writes to prevent false sleep/wake detection during profile switches.

πŸ–οΈ Gesture System

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 : Inject keystroke<br/>(Up/Down/Left/Right)
    ExecuteClick --> Idle : Inject keystroke<br/>(Click gesture)
Loading

Direction resolution:

Condition Direction
|dx| > |dy| and dx < 0 ⬅️ Left
|dx| > |dy| and dx > 0 ➑️ Right
|dy| > |dx| and dy < 0 ⬆️ Up
|dy| > |dx| and dy > 0 ⬇️ Down
Neither exceeds threshold (50 units) πŸ–±οΈ Click

Note

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.

πŸ”„ Thumb Wheel

βš™οΈ Mode Processing

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:

  1. Normalized by thumbWheelDefaultDirection (read from ThumbWheel GetInfo) so clockwise = positive
  2. Accumulated in m_thumbAccum
  3. Thresholded at kThumbThreshold = 15 to convert continuous rotation into discrete steps
  4. Executed as the appropriate action for each step

🧭 Direction Normalization

Tip

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.

πŸŽ›οΈ AppController Wiring

AppController is the central orchestrator. It owns all subsystems and wires them together:

graph TB
    subgraph "Owned Subsystems"
        Registry[DeviceRegistry]
        DevMgr[DeviceManager]
        PE[ProfileEngine]
        AE[ActionExecutor]
        DM[DeviceModel]
        BM[ButtonModel]
        AM[ActionModel]
        PM[ProfileModel]
    end

    subgraph "Injected (or created)"
        Desktop[IDesktopIntegration]
        Injector[IInputInjector]
    end

    subgraph "Signal Connections (wireSignals)"
        S1["ButtonModel::userActionChanged -> onUserButtonChanged"]
        S2["IDesktopIntegration::activeWindowChanged -> onWindowFocusChanged"]
        S3["ProfileModel::profileSwitched -> onTabSwitched"]
        S4["ProfileEngine::displayProfileChanged -> onDisplayProfileChanged"]
        S5["DeviceManager::deviceSetupComplete -> onDeviceSetupComplete"]
        S6["DeviceModel::userGestureChanged -> saveCurrentProfile"]
        S7["ProfileModel::profileAdded -> ProfileEngine::createProfileForApp"]
        S8["ProfileModel::profileRemoved -> ProfileEngine::removeAppProfile"]
        S9["DeviceManager::gestureRawXY -> onGestureRawXY"]
        S10["DeviceManager::divertedButtonPressed -> onDivertedButtonPressed"]
        S11["DeviceManager::thumbWheelRotation -> onThumbWheelRotation"]
        S12["DeviceModel::dpiChangeRequested -> onDpiChangeRequested"]
        S13["DeviceModel::smartShiftChangeRequested -> onSmartShiftChangeRequested"]
        S14["DeviceModel::scrollConfigChangeRequested -> onScrollConfigChangeRequested"]
        S15["DeviceModel::thumbWheelModeChangeRequested -> onThumbWheelModeChangeRequested"]
        S16["DeviceModel::thumbWheelInvertChangeRequested -> onThumbWheelInvertChangeRequested"]
    end
Loading

πŸ’‰ Dependency Injection

AppController accepts optional IDesktopIntegration* and IInputInjector* in its constructor:

AppController(IDesktopIntegration *desktop, IInputInjector *injector, QObject *parent = nullptr);
Scenario Behavior
🏭 Production (nullptr passed) Creates KDeDesktop and UinputInjector internally
πŸ§ͺ Tests (mocks injected) Uses MockDesktop and MockInjector for deterministic behavior

Note

The injected pointers are not owned by AppController (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 AppController, which simplifies lifetime management.


Logitune Wiki


🏠 Home

πŸ“š User Guide

πŸ—οΈ Architecture

πŸ”§ Extending

πŸ§ͺ Quality

Clone this wiki locally