Files
gKeyMapper/docs/daemon-plan.md
2026-10-17 03:53:51 +02:00

20 KiB
Raw Blame History

gKeyMapper daemon — implementation plan

Status: draft / proposal Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR and media keys) on the Logitech G910 and replays the mappings stored by the GUI.

Implementation status

  • M0 done — shared/mapping.{h,cpp} (file format + shortcut parsing) and shared/keys.{h,cpp} (canonical names → Linux codes); the GUI uses them. Round-trip and shortcut parsing are covered by a small standalone test.
  • M1 done — daemon/ builds (gkeymapper-daemon): device discovery, kernel-driver detach, F-key binding disable, 64-byte interrupt reads and G1–G9 decoding. Verified on hardware: all nine keys decode correctly (press and release), in order.
  • M2 done — action execution: key shortcuts are replayed through a /dev/uinput virtual keyboard, command runs via fork/exec (with a shell fallback so arguments work), disabled does nothing. Verified on hardware.
  • M3 done — config watching and lifecycle: daemon/config_watch watches the config directory with inotify, so a save in the GUI reloads within about half a second; SIGHUP/SIGUSR1 stay as the fallback. Save mappings now writes atomically (temp file + rename), so the daemon never reads a half-written file. make install ships the udev rule, the modules-load.d fragment and the systemd user unit (data/).
  • Layout handling (added during M2) — key names are resolved through the active layout (XKBLAYOUT from /etc/default/keyboard, via xkbcommon). This matters because the US-position code KEY_Z (44) is y on a German keyboard; z is now correctly resolved to evdev 21.
  • AltGr (added after M2) — level-3 combinations are representable as altgr+7; the base (level 0) key is stored plus the altgr modifier.
  • Next: M4 (D-Bus GUI channel) — status, explicit Reload, TestMapping and signals. The reload side of it is already covered by the M3 file watch.

1. Goals and non-goals

Goals

  • Detect G-key presses in C/C++ and execute the configured action.
  • Run with a very small, stable resource footprint (idle ≈ 0 % CPU, few MB RSS, no interpreter, no polling when nothing happens).
  • Reuse the existing mappings.conf format as the single source of truth.
  • Let the GUI and the daemon run independently; either can start first.
  • No Python at runtime.

Non-goals (v1)

  • Profiles (M1–M3/MR), per-locale text typing (typeout), embedded scripting.
  • GUI redesign beyond a status indicator / reload / test button.

2. What the Python reference does (and what we keep)

Reference: https://github.com/JSubelj/g910-gkey-macro-support

  • G-keys are not exposed as distinct evdev key codes. The driver therefore opens the keyboard's vendor HID interface (USB interface 1) directly via libusb, detaches the kernel driver from that interface, and reads raw interrupt-IN reports.
  • On start it sends a control transfer to disable the firmware F-key→G-key binding, so pressing G1..G9 no longer also emits F1..F9 on the normal keyboard interface.
  • Raw report byte patterns are looked up in tables (events.macroKeys, memoryKeys, mediaKeys, releaseEvents) and turned into actions.
  • A uinput virtual keyboard replays shortcut (press, later release), typeout (per-char), uinput (direct KEY_*); run uses /bin/bash -c.
  • Config is JSON at ~/.config/g910-gkeys/config.json, reloaded on directory change (inotify via fcntl), plus SIGUSR1 reload.
  • Ship: a systemd --user service and udev rules with TAG+="uaccess" for the device and /dev/uinput, so no root is needed.

Relevant USB IDs (from the udev rules): 046d:c335, c33f, c33e, c32b, c24d, c22d. The G910 in this project is 046d:c32b.

What we keep: libusb raw HID access, disabling the F-key binding, uinput output, config-dir watching. What we change: JSON→our line format, single-threaded event-driven loop instead of a Python loop with fixed sleeps, fork/exec reaping, and a real IPC channel to the GUI.

3. Architecture overview

+---------------------+        mappings.conf (shared truth)      +----------------------+
|  gkeymapper (GUI)   | <------------ write / inotify ---------> |  gkeymapper-daemon   |
|  GTK4 + GIO/GDBus   |                                          |  libusb + uinput     |
|                     | <===== D-Bus (session bus) ============> |  GDBus + inotify     |
+---------------------+        status / reload / events          +----------+-----------+
                                                                            | raw HID (iface 1)
                                                                            v
                                                                  +----------------------+
                                                                  | Logitech G910        |
                                                                  +----------+-----------+
                                                                             | uinput
                                                                             v
                                                                  +----------------------+
                                                                  | /dev/uinput keyboard |
                                                                  +----------------------+

Key property: the hot path never crosses IPC. G-key press → decode → uinput happens entirely inside the daemon. IPC (D-Bus) is only used for control, status and notifications, so a slow/absent GUI never delays a keypress.

4. Daemon design

4.1 Process model

  • One process, single-threaded, event loop.
  • No threads in v1. run/command actions are launched with fork() + execve(); children are reaped via a SIGCHLD/signalfd handler so no zombies accumulate (the reference leaks a Popen per invocation).
  • python type is intentionally dropped.

4.2 Event loop

Use one poll()/epoll() loop over:

  • the libusb file descriptor (from libusb_get_pollfds(); use libusb's async API or libusb_interrupt_transfer on a worker, see below),
  • an inotify fd for the config file/directory,
  • a signalfd for SIGTERM/SIGINT/SIGUSR1,
  • the D-Bus connection (GDBus integrates with the same GMainContext).

Preferred: drive libusb asynchronously with libusb_submit_transfer() and fold the reported poll fds into the same loop (libusb has an official "poll with another event loop" pattern). That yields exactly 0 wakeups while idle (excluding the kernel's own HID interrupts). If that is too much work for v1, fall back to a blocking libusb_interrupt_transfer(..., timeout=1000) inside a dedicated thread that posts events into the main loop — still cheap (1 wake/s).

To keep GDBus and the fd loop together, the loop is easiest on the GLib main loop (GMainLoop): GDBus, GFileMonitor and GSocket/GSource cover inotify and signalfd, and libusb poll fds can be wrapped in a custom GSource. GLib is already a dependency of the GUI, so this adds no new library. If minimal footprint is the priority over convenience, a raw epoll loop is the alternative; decision point in §11.

4.3 Device layer (libusb)

  • Find the device by vendor/product from the supported-device table; pick interface 1, endpoint from the same table.
  • libusb_detach_kernel_driver() on interface 1; remember to libusb_attach_kernel_driver() on shutdown (reference does this).
  • Send the "disable F-key to G-key binding" control transfer (bmRequestType=0x21, bRequest=0x09, wValue=0x0212, wIndex=1) and consume the confirmation reports; ignore the known LED-return patterns (\x11\xff\x0f, \x11\xff\x10, \x11\xff\xff).
  • Hot-plug / re-plug: on LIBUSB_ERROR_NO_DEVICE/IO re-run discovery with a small backoff (reference re-inits in place).
  • Optional (later): libusb_hotplug_register_callback to react to device add/remove without polling.

The device table in supported_devices.py (byte patterns per model) is ported to a generated C++ header (constexpr tables) — keep it a build-time artifact so the runtime does zero parsing.

4.4 Decoding

  • The report is a fixed-length byte blob. Build a compile-time lookup: a sorted array of {report bytes, action id} with binary search, or a small open-addressed hash keyed on the first 8 bytes. This replaces the Python dict + list(...).index() O(n) scans with O(1)/O(log n) and no allocation.
  • Categories: macro (G1..G9), memory (profiles, later), media (later), release (stops a held shortcut), and ignored/known-noise.

4.5 Action layer (uinput)

  • Open /dev/uinput once; UI_SET_EVBIT for EV_KEY, register the key set used by the mappings, UI_DEV_CREATE. Keep the fd for the daemon's lifetime.
  • Emit events by write()-ing struct input_event (from <linux/uinput.h> / <linux/input-event-codes.h> — no libevdev needed).
  • key mapping = shortcut grammar ctrl+c,ctrl+v:
    • single combo: press modifiers+key in order, remember the keys, release them when the keyboard's release event arrives (mirror the reference's press_release_fifo stack).
    • comma list: emit_combo() each group (press+release).
  • command mapping: fork(); in the child setsid(), redirect stdio to /dev/null, execve("/bin/sh", "-c", cmd). Reap via SIGCHLD.
  • disabled: no-op.
  • Give the uinput device a clear name/IDs (e.g. gkeymapper virtual keyboard) so libinput/users can identify it.

4.6 Config

  • Parse the existing mappings.conf: G<n>=<type>:<target> with type ∈ {key, command, disabled}.
  • Parse at load and on change only; the runtime holds a small pre-parsed action per G-key (enum + fixed target buffer / std::string), so no parsing in the hot path.
  • Watch the config file (and, for atomic-save editors, its directory) with inotify/GFileMonitor; reload on IN_CLOSE_WRITE/IN_MOVED_TO. Debounce (e.g. 100 ms) to coalesce editor save bursts.
  • Also reload on SIGUSR1 (manual/test) and on Reload() D-Bus call.

4.7 Privileges and packaging

  • udev rule with TAG+="uaccess" for the supported USB IDs and for KERNEL=="uinput" (mirrors the reference's 60-g910-gkeys.rules).
  • uinput kernel module must be present; ship /etc/modules-load.d/ fragment or document modprobe uinput (here it is built in).
  • systemd --user unit gkeymapper-daemon.service: Type=dbus, BusName=…, ExecStart=gkeymapper-daemon, Restart=on-failure, Documentation=…. BusName enables D-Bus activation, so starting the GUI can start the daemon on demand.
  • Do not require root. Commands run with the user's privileges (the reference's "executed as root" note is a wiki artifact, not a design goal).

5. GUI ↔ daemon interaction

Three layers, from loosest to tightest:

  1. Shared state = the config file (~/.config/gKeyMapper/mappings.conf). The GUI is the writer, the daemon is a watcher/reader. This alone is enough for basic operation and is robust: whichever side starts first, the other converges. The daemon reloads via inotify; no coupling to the GUI process.
  2. Control/status = D-Bus on the session bus (GDBus; part of GIO, already linked by the GUI). Used for status, explicit reload, and notifications.
  3. Lifecycle: the daemon is a systemd --user service; the GUI can start it implicitly by calling a D-Bus method (activation) and can show whether it is running.

5.1 Proposed D-Bus interface

  • Bus name: <app-id> — decide once (§11). Suggested: dev.rager.gkeymapper (replace the current placeholder com.example.gkeymapper).
  • Object path: /dev/rager/gkeymapper/Daemon
  • Interface: dev.rager.gkeymapper.Daemon

Methods

Method Signature Purpose
Reload () -> () Re-read mappings.conf now (GUI "Save" also calls this).
GetStatus () -> (s version, b running, s device, s configPath) Status for the GUI footer/indicator.
TestMapping (u index) -> () Fire mapping for G1..G9 without hardware (GUI "Test" button).
SetProfile (s profile) -> () Reserved for M-key profiles (later).

Signals

Signal Signature Purpose
KeyPressed (u index, s action) A G-key fired (for GUI highlight/history).
ConfigReloaded (b ok) Daemon finished reloading.
DeviceChanged (b present, s device) Keyboard attached/detached.

Notes

  • All are control-plane calls; none is on the keypress path.
  • Reload is best-effort: inotify is the backstop if the call is lost.
  • If D-Bus is unavailable, the daemon still works; the GUI degrades to "no status" and "save only". Signals SIGUSR1 (reload) / SIGTERM remain for scripted use, matching the reference.

5.2 Interaction sequences

Save in the GUI:

GUI: write mappings.conf (atomic: temp + rename)
GUI: call Daemon.Reload()   [optional, for immediacy]
Daemon: inotify IN_MOVED_TO -> (re)parse -> apply
Daemon: emit ConfigReloaded(ok)

Key press:

G910 --raw HID--> Daemon -> decode -> uinput emit / fork+exec
Daemon: emit KeyPressed(index, action)   [GUI only, non-blocking]

Start-up orders:

  • Daemon first, GUI later: GUI reads status via D-Bus; no config write needed.
  • GUI first, daemon later: GUI writes config; on daemon start it reads the file.

5.3 GUI-side changes (small)

  • On "Save mappings": keep writing the file, then call Reload() (ignore error).
  • Add a footer status line: daemon running / device present / last reload, fed by GetStatus + the DeviceChanged/ConfigReloaded signals.
  • Optional "Test" button per row → TestMapping(index).
  • The key-name vocabulary must match: the capture dialog already writes lowercase GDK key names; move the canonical name list into the shared module (§6) so GUI and daemon agree on the same spellings.

6. Code layout

src/                     # GUI (existing)
daemon/                  # as implemented (see the list in §6 above)
shared/
  mapping.{h,cpp}        # parse/format mappings.conf (used by GUI and daemon)
  keys.{h,cpp}           # canonical names <-> gdk keyval <-> linux KEY_*
  keymap.{h,cpp}         # layout-aware name <-> input code (xkbcommon)
data/
  60-gkeymapper.rules    # udev: uaccess for the device and /dev/uinput
  gkeymapper-uinput.conf # modules-load.d fragment (uinput)
  gkeymapper-daemon.service # systemd user unit (@BINDIR@ filled in on install)
po/                      # shared catalogs (GUI strings)

Build: the Makefile gets a second target gkeymapper-daemon and install rules for the unit, udev rule and modules-load fragment. New build dependency: libusb-1.0-0-dev (GLib/GIO is already required by the GUI).

7. Resource-efficiency measures (summary)

  • No Python interpreter; native binary, tables in .rodata.
  • Event-driven loop, no idle polling (libusb async + inotify + signalfd; or a 1 s blocking USB read in the simple variant).
  • O(1)/O(log n) decode via compile-time tables instead of dict/linear scan.
  • One uinput fd for the process lifetime; fixed-size event writes; no per-event heap allocation.
  • Config parsed only on load/reload; hot path is table lookups + write().
  • fork+exec for programs, children reaped (no zombies), stdio to /dev/null; no shell parsing for key/uinput actions.
  • Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events, RSS in the low single-digit MB.

8. Milestones

  • M0 – Shared core. shared/mapping + shared/keys; refactor the GUI to use them (still file-only). Unit tests for parse/format and key tables.
  • M1 – Device bring-up. daemon opens the G910 via libusb, disables the F-key binding, logs G1..G9 presses. No output yet.
  • M2 – Action layer. uinput keyboard; key (shortcut, incl. release) and command work end-to-end. disabled no-op.
  • M3 – Config & lifecycle (done). config reload via inotify (config_watch, directory watch + atomic save) and SIGHUP/SIGUSR1; systemd --user unit + udev rule + modules-load, installed by make install.
  • M4 – IPC. GDBus service (Reload, GetStatus, TestMapping, signals); GUI status line + Reload-on-save + optional Test button.
  • M5 – Polish. Packaging/install rules, docs, translations, hardening (hot-plug, debounce, error reporting to the GUI).

9. Testing strategy

  • Unit: mapping parser round-trip, key-name tables, decoder against recorded byte sequences from the reference tables.
  • Device: replay captured reports into the decoder (no hardware needed).
  • Integration: evtest / libinput debug-events to observe the virtual device; compare xev output for a configured shortcut between reference and daemon.
  • Permission tests: verify the udev rule grants access as a normal user.
  • Resource check: powertop/perf or simple wakeup counting while idle.

10. Risks / open points

  • Device table size: supported_devices.py is large; porting/generating the byte tables is real work and model-specific.
  • Interface detach may momentarily disturb the device; must re-attach on exit and handle crashes gracefully.
  • Permissions: relies on TAG+="uaccess" + a user service; must never fall back to requiring root silently.
  • command environment: DISPLAY/WAYLAND_DISPLAY/XAUTHORITY and PATH differ between a user service and an interactive shell; decide whether to inherit the user manager environment (systemd --user import-environment) or document run snippets setting DISPLAY=:0 explicitly.
  • Key-name vocabulary drift between GUI (GDK names) and daemon (Linux KEY_*) is the most likely source of silent mismatches — that is why the shared table in M0 is mandatory before the daemon consumes the file.
  • uinput device identity: a second keyboard appears; choose stable name/vendor/product so the desktop does not confuse it with the real one.

11. Decisions to make

  1. Bus name / app-id: replace com.example.gkeymapper with a real reverse-DNS id (suggested dev.rager.gkeymapper)? Affects the .desktop name too.
  2. Event loop: GLib GMainLoop (simplest, reuses GIO) vs. raw epoll (leanest).
  3. IPC: D-Bus (idiomatic, GIO already present) vs. a Unix socket (no bus dependency). Recommendation: D-Bus, with signals as a fallback.
  4. Scope of v1: keep key/command/disabled only, or also port typeout/media?

Resolved

  • Event loop (as implemented, deviates from §4.2). The daemon is single-threaded with a blocking libusb_interrupt_transfer and a 500 ms timeout; the config watch is drained once per iteration. That is ~2 idle wakeups/s at 0 % CPU (the "simple variant" of §4.2) rather than the preferred async libusb + GMainLoop design. Switching to libusb's async API with its poll fds in a poll() loop stays open if idle wakeups ever become a concern — the config watch would fold into the same set.
  • Layout handling (done in M2). Key names in the file are layout independent; the daemon resolves them through the active layout (XKBLAYOUT from /etc/default/keyboard, via xkbcommon) and only falls back to the US positions from shared/keys. This closes the vocabulary-drift risk for non-US layouts — a German z is evdev 21, not the US position 44.
  • Reload without restart (done in M2). SIGHUP re-reads the config file (the full D-Bus Reload comes with M4).
  • Config watching (done in M3). The daemon watches the config directory with inotify rather than the file, so an atomic save (temp file + rename) and a recreated file are caught too. The loop iteration (≤ 500 ms) is the coalescing window, so one save yields exactly one reload; SIGHUP/SIGUSR1 stay as the fallback and there is no extra fd in the loop. Save mappings in the GUI now writes atomically, so the daemon can never read a partial file. A failed reload keeps the previous mappings instead of disabling every key.
  • Lifecycle (done in M3). A systemd user unit (not a system service): the daemon needs the session for /dev/uinput and for the DISPLAY a command action inherits. It is Type=simple for now; it becomes Type=dbus with a BusName once M4 adds the D-Bus service. The udev rule and the modules-load.d fragment live in data/ and are installed by make install (under PREFIX/lib/..., which udev and systemd both search).