Nicer structure
This commit is contained in:
335
docs/daemon-plan.md
Normal file
335
docs/daemon-plan.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 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.
|
||||
|
||||
## 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/
|
||||
main.cpp # event loop, wiring
|
||||
device_libusb.{h,cpp} # discovery, detach/attach, read, disable-binding
|
||||
decoder.{h,cpp} # report bytes -> action id (compile-time tables)
|
||||
uinput_keyboard.{h,cpp}# virtual keyboard, emit / combo / press-release stack
|
||||
actions.{h,cpp} # key / command / disabled execution, child reaping
|
||||
config.{h,cpp} # mappings.conf parse + watch
|
||||
ipc_dbus.{h,cpp} # GDBus service
|
||||
generated/devices.inc # ported from supported_devices.py
|
||||
shared/
|
||||
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
||||
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
||||
ipc_ids.h # D-Bus name/path/interface constants
|
||||
data/
|
||||
60-gkeymapper.rules
|
||||
gkeymapper-daemon.service
|
||||
uinput.conf # modules-load.d
|
||||
po/ # daemon strings, reuse the existing catalogs
|
||||
```
|
||||
|
||||
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.** config parse/reload (inotify, SIGUSR1), signals,
|
||||
systemd --user unit + udev rule + modules-load.
|
||||
- **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`?
|
||||
Reference in New Issue
Block a user