GitHub - ascendedent/loupedeckapp: Linux configuration app for the Loupedeck CT / Live (PySide6 + QML). Origin: inspired by flowernert/loupedeckapp. · GitHub
Skip to content

About

Linux configuration app for the Loupedeck CT / Live (PySide6 + QML). Origin: inspired by flowernert/loupedeckapp.

Resources

Stars

7 stars

Watchers

1 watching

Forks

Latest commit

 

History

154 Commits

Folders and files

Repository files navigation

loupedeckapp

A native Linux configuration app for the Loupedeck CT (and Loupedeck Live / Live S): an open-source stand-in for the official Loupedeck software, which is Windows/macOS only.

Assign images, background colours, text labels, LED colours and actions to every touch key, side display, encoder, and (on the CT) the rotary dial and round wheel screen; organise them into workspaces and nested submenus; and optionally auto-switch profiles based on the focused desktop application. All from a modern, dark PySide6/QML interface.

Origin. This project began as a fork of flowernert/loupedeckapp (which targets the Loupedeck Live) and has grown into a standalone app focused on the Loupedeck CT with full Wayland support. Thanks to flowernert for the original groundwork.


Supported hardware

Device USB PID Status
Loupedeck CT 2ec2:0003 Primary target; full support incl. the 240×240 wheel screen, rotary dial, and CT buttons
Loupedeck Live 2ec2:0004 Supported, never tested on hardware (help wanted)
Loupedeck Live S 2ec2:0006 Geometry corrected from one owner's report; the fix has not been run on hardware (help wanted)

The model is detected from the USB product id. The vendored device library reports every model as LoupedeckLive and knows only that model's screen layout, so each of the others patches it at runtime: ct_support.py adds the CT's wheel, dial and extra buttons, and live_s_support.py corrects a Live S's single 480-wide screen and its five-column key grid. The device view draws only the controls the detected model has, so a Live shows no wheel and a Live S shows a 5-column grid with two dials on the left, one round button under them and three down the right.

Set LOUPEDECKAPP_MODEL to ct, live, or live-s to override the detected model. That is there for two cases: a device whose product id is not in the table above, and checking the device view for a model you do not own. Profiles always store the widest grid (5 columns) and all eight workspaces, so a file written on one model opens on another.

Features

Device control

  • 4×3 touch-key grid, left/right side displays, and 6 side encoders (press + rotate).
  • On the CT: the round wheel screen (renders big-endian 240×240), the rotary dial (press + rotate), and the CT function buttons (home, undo, keyboard, enter, save, fn, a-e).
  • 8 workspaces on the physical buttons (circle + 1-7), each with its own layout, plus nested submenus.

Per-control styling

  • Images: fit to the control (aspect preserved, never cropped or stretched). The inspector shows the exact target size for a pixel-perfect fill (90×90 keys, 60×90 side cells, 240×240 wheel).
  • Text labels: on by default, auto-derived from the assigned action's name (e.g. Copy), with a per-control show/hide toggle so a label is never lost when you add an image. Placement top / middle / bottom, and three modes: over the image, on a bar, or shrink (image resized so the label sits beside it). Custom bar colour.
  • Background colour behind an image, and RGB LED colours for the physical workspace and CT buttons.
  • Everything renders both in the on-screen mirror and on the physical device.

Actions

  • command/launch, hotkey, text, scroll, mouse, media (MPRIS), macro (a sequence), keyboard (the desktop's on-screen keyboard), workspace (jump to a workspace), page (turn a page of keys), plugin (an action of an installed Loupedeck plugin, below), and submenu / back navigation.
  • mouse clicks (left, right, middle, double), scrolls, moves the pointer or drags, with keys held around it when it says so (ctrl+click, ctrl+scroll-up). On a knob, each notch moves, scrolls or drags a step further; a click happens once.
  • Hotkey recorder that captures a key combination when you press it, plus a presets picker that includes your machine's configured KDE global shortcuts.
  • A searchable action library you drag onto controls, including scroll and volume. Search matches the action's value and type as well as its name, so ctrl, scroll or vol up all narrow the list. Dropping onto an encoder, the dial, or the wheel lets you pick the press / rotate / touch slot.

Macros

Bind a sequence to one control. A macro is written a step per line:

hotkey ctrl+c
wait 200
hotkey alt+tab
text hello world

Steps are hotkey, text, wait <ms>, scroll <direction> [count], mouse (as the action: mouse ctrl+click), media, keyboard and command. Lines starting with # are comments.

The inspector offers both a step list (add, reorder, delete, with a dropdown per step) and the raw text, switchable with one button. They edit the same value, so use whichever suits: the list for building a macro, the text for pasting or editing one quickly. The text view reports the step count and points at any line it cannot read, so a typo is visible without pressing the button and wondering.

Macros run on their own worker: pressing a button never blocks the device while a macro with waits in it plays out, and two macros run one after the other rather than interleaving.

Loupedeck plugins

Plugins from Loupedeck's and Logitech's marketplace (.lplug4) install from the gear menu, Install plugin…, or from a terminal:

.venv/bin/python plugin_host.py install SomePlugin.lplug4    # also: list, remove NAME

Both kinds run:

  • Node.js plugins (Logitech's newer SDK, pluginRuntime: nodejs) run as a node process. Needs Node.js 22 or newer.

  • .NET plugins (the older and larger catalogue, a .dll in the package) run against dotnet/PluginApi: a clean-room stand-in for Logitech's PluginApi.dll, written from their public documentation, their sample plugins and the shapes compiled plugins reference. It is built from source the first time a .NET plugin starts, two ways:

    • plugins built for .NET 8 or 10 (current Logi plugins: Krita, Affinity Photo) run on the .NET runtime: sudo dnf install dotnet-sdk-10.0. Drawing there is SkiaSharp, fetched from NuGet on that first build.
    • plugins built for .NET Framework (the Loupedeck 5 catalogue: Teams, Twitch, Spotify) run on Mono (sudo dnf install mono-complete), or on .NET when Mono is not installed.

    When one is installed, the app says which parts of the API it uses that are not implemented here yet.

Either way the plugin talks to the app over a local WebSocket the way it would talk to Logitech's software. Once it is running, its actions appear in the library under the plugin's name and in the inspector under the plugin type. Commands go on keys; an adjustment appears twice, ◀ and ▶, for the two rotate slots of an encoder or the dial (and as a third, reset, for its press, where the plugin has one); a fast turn reaches the plugin as a larger click count. A command that offers several variants (a .NET plugin's parameters) appears once per variant.

A key bound to a plugin action shows the plugin's own image: one it draws (.NET plugins mostly do), or the icon its package ships (actionicons/<action>.svg). Its label follows the action's name live: a counter, a status or a clock is redrawn within about a second, and immediately after a press. Only the keys showing plugin actions are redrawn, so nothing else blinks.

Some actions are set up per binding. A profile action asks for one value (a message to send, an item from a list or a tree); an action editor action has a small form (text, lists the plugin fills, checkboxes, sliders). Either way the inspector shows it under the action, and the values are kept with that key's binding, so the same action can be on several keys with different values.

A plugin's folders open as a page of their own: bind the folder to a key, press it, and the keys and knobs show what the plugin puts there (Krita's tool pages, a playlist), with Back on the first key and ◀ ▶ to page through when there are more than fit. The plugin can change the page while it is open, and close it.

A plugin that asks to sign in to a service shows a sign-in link under it in the gear menu; the plugin does the signing in (usually in your browser) and the link turns into its sign-out. Plugins whose browser sign-in comes back on a loupedeck: link get that link registered with the desktop when they are installed (~/.local/share/applications/loupedeckapp-url.desktop and xdg-mime), and the running app passes it to the plugin. Its settings and sign-ins live in ~/.config/loupedeckapp/Plugins/<name>.data/, so updating the plugin keeps them. A few plugins fetched a configuration file from Loupedeck's own servers; a copy put in <name>.data/online-files/ is used instead.

A plugin that exits on its own is restarted, after 1, 2, 4, 8 and 16 seconds. One that keeps dying (six times in two minutes) is left stopped, and the gear menu says so and offers a restart. Its console output is in ~/.config/loupedeckapp/Plugins/<name>.log.

Plugins can type text and use the clipboard (wl-copy/wl-paste, or xclip on X11), and get Newtonsoft.Json 13 from the host, as the official software provides it; the build fetches it from NuGet and checks it against a pinned hash. A package that ships only a Windows build is installed with a note saying it may not run: most such plugins call into Windows itself.

dotnet/tools/ApiScan.cs lists what any .NET plugin needs from the API, return types included, and marks what is missing, which is the checklist for supporting a new one:

python3 dotnet/build.py
MONO_PATH=dotnet/bin mono dotnet/bin/ApiScan.exe Some.dll dotnet/bin/PluginApi.dll

plugin_port.py says what ties a Windows or macOS plugin to its platform, without running it: calls into Windows or macOS libraries, COM interfaces, Windows-only libraries it bundles or has merged into itself, the registry, shell commands, and the application it controls. Findings are grouped by what a Linux stand-in would have to do (input, windows, audio, clipboard...), and given a folder of packages it ranks those by how many plugins each would unlock:

python3 plugin_port.py Some.lplug4         # one plugin, in detail
python3 plugin_port.py ~/Downloads         # every .lplug4 there, and what to write first

What has a Linux stand-in is converted when the plugin is installed, and the install note says what was converted and what is still tied to Windows or macOS:

  • pages and files a plugin opens the Windows or macOS way (cmd /c start, explorer, rundll32 url.dll, open, or a URL protocol the plugin registered itself, as the Weather plugin does) open with xdg-open;
  • bundled libraries with a stand-in are replaced by it:
    • WindowsInput (InputSimulator) sends its keys, text and wheel through the app's input backend, holding keys for real (Ctrl held while the wheel turns zooms, as on Windows; a plugin that exits holding a key has it let go);
    • AudioSwitcher (Windows' audio endpoints, as VolumeControl uses them) lists, sets and mutes PipeWire's sinks and sources through pactl.

dotnet/tools/PortPatch.cs does the editing; the originals are kept in the plugin's .orig/ folder (python3 plugin_port.py revert <name> puts them back), and .port.json records what was done and with which rules and stand-ins. Converting needs Mono or the .NET SDK: with Mono the tools read plugins with Mono's Mono.Cecil and the stand-ins are built for .NET Framework (which both runtimes load); with only the .NET SDK the same tools and stand-ins are built with it (dotnet/core/Linux.slnx, Mono.Cecil from NuGet). Without Mono, plugins that use the Windows registry (Weather registers itself there) do not convert: .NET on Linux has no registry, Mono keeps one. When the rules or stand-ins change (the app was updated), plugins converted before are converted again when the app starts, before they run; so are plugins installed before conversion existed. Mono's machine registry is per plugin (MONO_REGISTRY_PATH in the plugin's data folder), so plugins that register things at load work.

What no rule covers yet is listed when the plugin is installed. python3 plugin_port.py brief <name> turns that into a brief for Claude Code (or anyone working on this source): what is left, where the rules and stand-ins live, and how to test them. Nothing is run for you; paste it into a session of your own, on a branch, and review what comes back.

Loupedeck profiles (.lp5)

Profiles exported from Loupedeck's own software, and the ones its marketplace offers for an application, import directly: Import app in the application panel takes a .lp5 as well as this app's own .json, or from a terminal:

.venv/bin/python lp5_import.py Slack_CT_Win.lp5

It becomes an application here, set to switch on that application's window, with its workspaces on the round buttons that chose them, its keys, knobs, square buttons, fn presses and key images where they were, and each knob's name on the side strip beside it. Keyboard shortcuts, macros of them (including macros that run other macros), typed text, mouse clicks, drags and the wheel (with keys held), the default plugin's volume and media actions, and further pages of keys come across; keys that turn to a page or switch workspace keep doing so. What does not (long and double presses, pages of knobs, the wheel's clock and other tools, other plugins' actions such as Loupedeck's own Premiere or Photoshop integrations) is listed when the import finishes rather than dropped silently. A profile made on Windows has Windows' Ctrl+Y redo turned into Ctrl+Shift+Z, which is what Chromium-based apps use elsewhere.

Pages of keys

A workspace can have more than one page of keys: swipe across the screen to turn them, as on Loupedeck's own software, or bind the page action (next, previous, or a page number) to any control. Pages share the workspace's knobs and buttons; only the keys differ. In the editor the corner of the device view shows "page 2 of 3" with arrows to turn, + to add a page and − to remove the one shown. On a workspace with pages a key fires when your finger lifts rather than on touch, which is how a tap is told from the start of a swipe; workspaces without pages are as before.

The fn layer

Every control can carry a secondary binding. Hold fn and controls fire that instead of their usual one; a control with no secondary keeps its normal behaviour, so fn never makes anything go dead.

fn is hold by default, which is how a modifier behaves. Click the fn: pill in the top bar to switch it to latch, where a press sticks until you press it again and the key lights up to show the layer is on. Either fn key drives the same layer. Changing workspace releases it, since the key-up would otherwise land on a different page and leave it stuck.

Set a secondary from the inspector: each slot has an fn row under its normal binding.

Click the fn: pill in the top bar to choose the mode and the two key colours: one for while the layer is on, one for while it is off. Leaving the off colour unset makes the fn keys behave like any other button, taking the LED colour from the workspace.

CT function buttons

New profiles start with the CT's labelled buttons wired to what they say: home goes to workspace 1, undo and save send ctrl+z / ctrl+s, enter sends Enter, and kbd toggles the desktop's on-screen keyboard (KDE's own, over DBus; squeekboard / wvkbd / onboard elsewhere). fn is left unbound, being a modifier rather than an action.

This applies to every profile as it loads, not only new ones, and only to slots that are still empty: anything you have bound yourself is left exactly as it is, and loading does not mark the profile as changed. Set "auto_bind_ct_buttons": false in ~/.config/loupedeckapp/settings.json to leave unbound buttons unbound.

Encoder feel

Per-control tuning for the six encoders and the CT dial, none of which the official app offers. Set from the inspector when a rotate control is selected; stored per workspace and inherited into submenus unless a submenu overrides it.

  • Invert direction.
  • Speed presets: Original, Slow 1/2, Slow 1/3, Fast 2x, Fast 3x. Slow banks detents and fires once per N (resetting if you reverse); Fast makes one detent worth N steps.
  • Acceleration: optional, off by default. Turning at a normal pace behaves exactly as usual; spinning the control ramps up to 10x. Speed is measured from the gap between detents, so the first click of a turn is never accelerated and small corrections stay predictable.

Defaults are calibrated against measured hand speeds on a CT side encoder (deliberate turning ~189 ms between detents, a comfortable pace ~58 ms, a full spin ~8 ms). scratch/probe_rotate.py re-measures this and suggests thresholds if the defaults do not suit you.

Acceleration pays off most on scroll, where the whole magnitude rides in one call. On a hotkey it is limited by how fast keystrokes can be delivered (see below), so it mainly helps at middling speeds.

Workflow

  • Three-column dark UI (PySide6 + QML): action library · live device mirror · inspector. The mirror is drawn as the hardware, knurled knobs and dial, domed keys lit by their own LED colour, glass over the screens, rather than as a schematic.
  • Draft editing: edits update the on-screen mirror live and are pushed to the hardware only on Save; Revert discards the draft. Nothing throws a draft away without asking, and if dynamic mode wants to switch profile mid-edit it waits (shown in the top bar) rather than interrupting with a dialog or losing the work.
  • Copy / paste a control's entire function onto another compatible control, or a whole workspace onto another one.
  • Side displays your way: three separate buttons, or one tall image for the whole strip, per workspace and per side.
  • A starter profile with Media, Editing and Browser workspaces, labelled and colour-coded, opened on a first run. After that the app reopens whatever profile you had last.
  • A ready-made Visual Studio Code deck: five workspaces (Edit, Navigate, Run, Git, View), 165 bound controls, chords done as macros and the Git commands driven through the command palette.
  • A ready-made Konsole deck: GitHub (pull, push, push-and-merge, npm run push-main), Dev, Agents, Projects, and Updates. On a CT, A–E switch those profiles. Commands are typed into the focused terminal.
  • Inspector in collapsible sections (Action, Appearance, Advanced), remembered as you move between controls.
  • Says what to do when there is nothing to see: an unbound workspace, a search that matched nothing, and an empty profile list each explain themselves rather than looking broken.
  • Name your workspaces. The header shows the name of the one on the device and falls back to "Workspace 3"; eight numbered keys say nothing about what is on them.
  • Switch workspace from the app by clicking a round key or pressing Ctrl+1..Ctrl+8, rather than reaching over to the device.
  • Keyboard shortcuts: Ctrl+S save, Ctrl+R revert, Ctrl+F search actions, Ctrl+C / Ctrl+V copy and paste a control, Esc leave a submenu or clear the selection. They stay out of the way while you are typing in a field.
  • Back up everything to one file, and restore it. Restoring adds; it never replaces what you have, because a restore that overwrites is one you cannot undo.
  • Import / export a profile, or a whole application with all its profiles, matching rules and pages in one file. Importing checks the file before adding anything and never overwrites: a name that is taken gets a numbered suffix, so someone else's Premiere setup cannot land on top of yours.
  • Dynamic mode: switches the deck to follow what you are doing, on two levels. Focus an application and its profile loads; change what that application is showing and a page inside it can load a different profile again. Reads the focused window through KWin on KDE, an extension on GNOME, _NET_ACTIVE_WINDOW on X11, and AppleScript on macOS.
  • Brightness control, remembered between runs and re-applied on reconnect.
  • Tells you when input is broken rather than silently doing nothing, with the reason and a re-check button.
  • Survives unplugging: the app connects when the device appears and reconnects when it comes back, returning to the workspace you were on. No restart needed.
  • JSON profiles (schema v8, backward compatible with older profiles; unknown fields written by a newer build survive a load/save round-trip).

Applications, profiles and pages

The same shape as the official software:

  • An application is the top level: Premiere Pro, OBS, or Default for everything else. It owns its profiles, and it carries the window classes that mean it is in front. Adding one offers a searchable list of what is actually installed on the machine, with the right window class already attached, so nothing has to be typed from memory.
  • A profile is one full deck: every key, encoder, label and colour, across eight workspaces. Profiles belong to an application, so two applications can both have a profile called "Edit" without one meaning the other.
  • A page switches profile inside an application. Premiere Pro is one app, but Cut, Edit and Sound each want a different deck. A page matches on the window title, which is the only signal finer than the window class that a Linux compositor gives us, and the first matching page wins, so their order is their precedence.

Resolution goes application, then page, then the app's own default profile, then the global fallback. On disk that hierarchy is just directories:

~/.config/loupedeckapp/Profiles/
├── Default/
│   ├── app.json          which profile this app uses, and what focuses it
│   └── Starter.json
└── Premiere Pro/
    ├── app.json
    ├── Cut.json
    └── Sound.json

Profiles written before applications existed are moved into Default the first time you run a build that has them. Nothing is lost and nothing is copied: a copy would leave an old file where the app no longer looks.

The installed-application list comes from the system's own records: XDG desktop entries on Linux (/usr/share/applications and the user, flatpak and snap locations), and .app bundles on macOS. The match key is whatever that platform considers authoritative, which is StartupWMClass on Linux (falling back to the executable's name) and the bundle identifier on macOS.

Where your data lives

Profiles you edit, app bindings, preferences ~/.config/loupedeckapp/ ($XDG_CONFIG_HOME is respected)
Installed Loupedeck plugins and their logs ~/.config/loupedeckapp/Plugins/
Profiles and applications you deleted ~/.config/loupedeckapp/Deleted/, twenty most recent, restorable from the gear
Profiles and images shipped with the app beside the code, never written to

Profiles resolve your copy first, then the bundled one, so anything shipped with the app appears in the list and editing it writes a copy rather than modifying the installation. Delete that copy and the original comes back. Profiles left in the source tree by earlier versions are copied across on first run, and the originals are left alone.

Set LOUPEDECKAPP_CONFIG_DIR to put user data somewhere else.

Requirements

  • Linux or macOS; Python 3 (developed on Fedora 44 / Python 3.14, KDE Plasma on Wayland; macOS exercised on 26.4, Apple Silicon).
  • The device on /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS). Linux needs it readable by your user (see Device permissions); macOS needs nothing.
  • Python 3.9 is enough for the app, but the device library asks for 3.10, so that is the real floor for anything that talks to hardware.

Python packages

  • PySide6 (QML UI), pyserial, pillow, and the devleaks python-loupedeck-live device library.
  • Optional: pyautogui + python-xlib, only as an X11 input fallback.

System tools

  • ydotool + the ydotoold daemon: injects hotkeys/text on Wayland via kernel uinput (required for actions to fire in native Wayland sessions).
  • playerctl: media transport via MPRIS.
  • kdotool: active-window detection for dynamic mode on KDE Wayland. On GNOME Wayland this needs a shell extension instead (GNOME gives applications no other way to ask); on X11, xprop. The Setup dialog says which, for the desktop you are on.

Setup

git clone https://github.com/ascendedent/loupedeckapp
cd loupedeckapp

python3 -m venv .venv
.venv/bin/pip install -e ".[device]"

# optional: X11 input fallback (unnecessary on Wayland)
.venv/bin/pip install -e ".[x11]"

# macOS: real media keys and real scrolling, which AppleScript cannot do
.venv/bin/pip install -e ".[macos]"

On macOS 10.14 or 10.15 add .venv/bin/pip install -e ".[legacy-macos]", which holds PySide6 at 6.2. Without it pip installs a newer Qt that those releases cannot load: 6.3 through 6.5.2 are tagged for 10.9 but need 10.15 or 11 at runtime, so they install without complaint and then fail to start. That extra caps Python at 3.10, the newest PySide6 6.2 built wheels for.

The device extra pulls the device library from git, since it is not on PyPI. Installing normally (pip install ".[device]") puts a loupedeckapp command on your PATH, the assets under <prefix>/share/loupedeckapp, and a desktop entry and icon where your menu will find them; the app finds its own files either way.

Leave the device extra off and the app still runs: it opens, edits and saves profiles, and says in the top bar that the device library is missing along with the command to install it. It just cannot find a device until you do.

First run

The app checks the machine when it starts and shows what is still missing: the udev rule and group for device access, the input backend, and the optional helpers (kdotool for dynamic mode, playerctl for media). Each one comes with the exact commands, which you copy and run yourself, because they need root and an app that asks for your password to run something you cannot read first is not one to trust with it.

It opens once on a first run. After that a Setup chip appears in the top bar only when something is wrong, and it is always under the gear.

System tray

The app sits in the tray by default, because it is only useful while it is running. Closing the window hides it rather than quitting; the tray menu has the live profile, a profile switcher, dynamic mode, show/hide and quit. Quitting from the tray with unsaved edits brings the window back and asks first.

All of that is under the gear in the top bar, along with brightness. If your desktop has no tray, the toggles are disabled and closing the window quits, since hiding it would leave no way back.

Distribution packages

A distribution package is the only format that can install the udev rule and depend on ydotool rather than telling you to. The Fedora spec in packaging/rpm/ builds and has been unpacked and checked; the Arch PKGBUILD in packaging/arch/ is a draft that has never been built. packaging/rpm/README.md says exactly what has and has not been verified.

rpmbuild -ba packaging/rpm/loupedeckapp.spec

A single file (AppImage)

./packaging/appimage/build.sh      # -> dist/LoupedeckConfig-x86_64.AppImage

Self-contained: the app, its dependencies, a Python interpreter and the parts of Qt it uses. It still needs the udev rule and ydotoold on the host, and the Setup dialog will tell you so.

There is a Flatpak manifest in packaging/flatpak/, but read its README first: typing into other applications is precisely what a sandbox exists to prevent, and working around that leaves little sandbox behind.

Starting with your session

Start with the session writes an XDG autostart entry (~/.config/autostart/loupedeckapp.desktop), which KDE and GNOME both read. Combine it with Start hidden and the app comes up in the tray with your profile already on the device.

From a checkout the entry has to name the interpreter and the script by absolute path, because a login session has neither your virtualenv nor the directory you launched from. If the app moves or its virtualenv is rebuilt elsewhere the entry goes stale, which is otherwise silent: the session still runs it and nothing starts. The preferences panel says so when that happens; turn the switch off and on again to repoint it.

Device permissions

Let your user reach the device without sudo. The rule covers all three models:

sudo cp packaging/99-loupedeck.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
sudo usermod -aG dialout "$USER"     # some distros use `plugdev`; match the rule

Log back in for the group change to apply.

Wayland input (ydotool)

sudo dnf install ydotool playerctl        # or your distro's package manager

# ydotoold stays root to open /dev/uinput, but its socket has to be reachable
# by you. This drop-in puts it somewhere predictable and hands it over:
sudo mkdir -p /etc/systemd/system/ydotool.service.d
sudo cp packaging/ydotool-user-socket.conf /etc/systemd/system/ydotool.service.d/override.conf
sudo systemctl daemon-reload && sudo systemctl enable --now ydotool

input_backend discovers that socket automatically, and falls back to xdotool / pyautogui on X11. See packaging/ for the files and what each one is for.

Building a deck

docs/HOW-TO-BUILD.md is the full walkthrough: applications, profiles, pages, workspaces, actions, encoder feel, and dynamic switching, end to end. It ends with a section for an AI assistant building a profile on someone's behalf, since the way that goes wrong is specific and avoidable.

The quick version: pick or add the application, make a profile in it, drag actions from the library onto the device, press Save. Turn on Dynamic and it follows what you are doing.

Running

loupedeckapp                  # if installed
.venv/bin/python qml_app.py   # from a checkout

# If Qt doesn't pick a platform on your session, set one explicitly:
#   QT_QPA_PLATFORM=wayland .venv/bin/python qml_app.py     # or =xcb for XWayland

Run the checks with .venv/bin/python tests/run_all.py (no device needed). Checks that do need hardware live in scripts/verify/.

On macOS? It runs. A Loupedeck CT on macOS 26.4 (Apple Silicon) enumerates as /dev/cu.usbmodem*, handshakes and identifies itself with no changes, all five setup checks pass, and the suite is green on both PySide6 6.2.4 / Python 3.10 and 6.11 / Python 3.14. What is still unproven is 10.14 and 10.15 themselves, which no one has run this on, and the Accessibility-gated paths: sending keystrokes and reading the frontmost app both need that permission granted, so they have been exercised no further than their own unit checks. docs/MACOS-TESTING.md is what to run and what to send back; docs/MACOS.md is the same ground for anyone changing the code.

Have a Live or Live S? Those models are supported from the library's source and published specs, and have never been run on real hardware. docs/LIVE-TESTING.md says what is guesswork and how to report what your device actually does.

Troubleshooting

An app only responds once to Fast 2x/3x or acceleration

You turn one detent expecting 3 steps and the app moves 1. The keystrokes are all being delivered; some receivers deliberately collapse identical keypresses that arrive with no gap between them, treating the burst as a single press.

We send repeats with a small gap for exactly this reason. If an app still under-delivers, that gap is too short for it. Raise repeat_delay_ms in input_backend.py:

class YdotoolBackend(InputBackend):
    repeat_delay_ms = 3      # try 8, then 15

It applies only when a repeat is sent, so raising it costs nothing on ordinary single presses. Measured against KDE's volume handler, 0 ms delivered 1 step of 3 every time and 1 ms delivered 3 of 3 every time, so the threshold can be very small; other handlers may want more.

The trade-off is throughput: a repeat costs two events at this delay, so 3 ms ceilings keystroke output near 167 steps/sec. Raising it lowers that ceiling, which matters only when spinning a control fast with Fast 3x or acceleration.

Actions do nothing in a Wayland session

The app shows a ⚠ input warning in the top bar when it cannot inject keystrokes; click it for the reason and a re-check button. Usually ydotoold is not running, or its socket is not readable by your user:

systemctl status ydotool
ls -l /run/.ydotool_socket        # should be owned by you

See the ydotool setup above for the drop-in that fixes socket ownership. input_backend picks ydotool automatically on Wayland; xdotool/pyautogui are X11-only and cannot inject into native Wayland clients.

The device is not found

Another instance may be holding /dev/ttyACM0 (including a scratch/ probe script). Otherwise check the udev rule and group membership under Device permissions.

Architecture

The core is Qt-free and layered, so the UI sits on top of reusable services:

Module Role
DeviceProfile Per-model geometry (screens, key maps) + USB-PID model detection.
ct_support Runtime support for the CT wheel / dial / buttons over the vendored library.
live_s_support The same for the Live S: one full-width screen, five key columns, and a touch decode to match.
input_backend OS input: ydotool → xdotool → pyautogui, auto-selected.
profile_store Creating, copying, deleting, importing and exporting profiles and applications on disk. Qt-free.
window_watcher / profile_manager Focused-app detection, and resolving it to an application, a page and a profile.
device_controller Connect, render a profile to the device, route events to actions, and dispatch rotate events through a coalescing queue.
LdConfiguration Profile data model + JSON persistence (schema v5), incl. encoder tuning.
platform_env The only module that reads sys.platform / XDG_* / DISPLAY.
action_library Ready-to-use actions, with per-desktop application entries.
app_paths Bundled assets vs. user data; profile resolution and legacy migration.
settings Small persistent app preferences (brightness), separate from profile data.
lp5_import Converts a Loupedeck 5 profile (.lp5) into an application here, reporting what has no equivalent.
plugin_host Installs Loupedeck plugins (.lplug4) and hosts them over the SDK's WebSocket protocol: one node process per Node.js plugin, one Mono process per .NET plugin.
dotnet/ The .NET side: PluginApi (clean-room stand-in for Logitech's API, plus the host that serves one plugin), LoupedeckHost.exe, and ApiScan (what a plugin needs vs what we have), and PortScan (what ties a plugin to Windows or macOS, read by plugin_port.py). Built by dotnet/build.py with Mono's mcs.
virtual_keyboard Toggles the desktop's on-screen keyboard (KDE via DBus, else a keyboard binary).
qml_app.py + qml/ The PySide6 / QML front-end.

See docs/PLAN.md for the full design notes and roadmap.

Roadmap

Agreed direction (detail in docs/PLAN.md):

  1. Ship Linux: pinned deps; Flatpak and/or AppImage; udev + ydotool docs; starter profiles.
  2. Product depth: Live/Live S mirror fidelity, macros, and UI polish.
  3. macOS: native build targeting macOS 10.14+. Device I/O is done and verified on real hardware, and the dependency floor now reaches 10.14 via the legacy-macos extra. What is left is the part behind the Accessibility permission — Quartz input and frontmost-app dynamic mode, neither confirmed on a Mac yet — then autostart as a LaunchAgent rather than an XDG entry, .app packaging, and a run on an actual Mojave machine.

Done recently: per-control encoder feel (invert, speed presets, acceleration on inter-detent interval), a scroll action, and a coalescing dispatch queue that keeps a fast spin from running on after your hand stops. Plus the QML UI reaching parity and the legacy PyQt5 tree being removed: binding a focused app to a profile, profile create/duplicate/rename/delete, and a working library search.

Plus a user-writable config location, so the app no longer stores your profiles inside its own source tree.

Smaller known gaps: encoder tuning is scoped per workspace rather than per profile, and the inspector exposes the speed presets but not the raw integers behind them.

Credits & license

Original project by flowernert. CT support, the Wayland-capable input backend, dynamic per-app profiles, and the PySide6/QML UI were added here. Device I/O uses devleaks' python-loupedeck-live.

See LICENSE.

About

Linux configuration app for the Loupedeck CT / Live (PySide6 + QML). Origin: inspired by flowernert/loupedeckapp.

Resources

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages