Media keys working again

This commit is contained in:
2026-10-18 03:50:33 +02:00
parent 726a7ae92d
commit 798708b3e7
20 changed files with 590 additions and 112 deletions

View File

@@ -44,6 +44,10 @@ void ActionRunner::run(const gkeymapper::Mapping& mapping) {
}
}
void ActionRunner::tap_key(unsigned short code) {
keyboard_.tap(code);
}
void ActionRunner::run_command(const std::string& command) {
if (command.empty()) {
return;

View File

@@ -18,6 +18,11 @@ public:
void run(const gkeymapper::Mapping& mapping);
// Replays one key press + release. Used by the media-key passthrough, which
// is not a mapping: the keyboard reports mute/volume/play only on the
// interface the daemon holds, so they are forwarded verbatim.
void tap_key(unsigned short code);
private:
void run_command(const std::string& command);

View File

@@ -5,6 +5,8 @@
#include <algorithm>
#include <initializer_list>
#include <linux/input-event-codes.h>
namespace gkeymapper::daemon {
namespace {
@@ -36,6 +38,25 @@ std::vector<Report> g910DisableCommands() {
return {report20({0x11, 0xff, 0x08, 0x2e, 0x01})};
}
// Media keys: a 2-byte report, "02" followed by one bit per key ("02 00" is
// "all released"). The bit assignment and the Linux codes they map to were
// measured on a G910 Orion Spark (2026-10-18) by reading the hidraw node of the
// vendor interface while the kernel driver owned it, and cross-checked against
// the key codes the kernel emits for that interface (input/event* with
// Phys …/input1). The keyboard reports these keys *only* there, so the daemon
// has to replay them once it holds the interface (see README).
std::vector<MediaKey> g910MediaKeys() {
return {
{0x01, KEY_NEXTSONG, "next track"},
{0x02, KEY_PREVIOUSSONG, "previous track"},
{0x04, KEY_STOPCD, "stop"},
{0x08, KEY_PLAYPAUSE, "play/pause"},
{0x10, KEY_VOLUMEUP, "volume up"},
{0x20, KEY_VOLUMEDOWN, "volume down"},
{0x40, KEY_MUTE, "mute"},
};
}
DeviceProfile make_g910(const char* name, uint16_t product, bool disableBinding) {
DeviceProfile profile;
profile.name = name;
@@ -45,7 +66,8 @@ DeviceProfile make_g910(const char* name, uint16_t product, bool disableBinding)
profile.interfaceNumber = 1;
profile.altSetting = 0;
profile.gKeyReports = g910GKeys();
profile.releaseReport = report20({0x11, 0xff, 0x08, 0x00});
profile.mediaPrefix = 0x02;
profile.mediaKeys = g910MediaKeys();
profile.disableBinding = disableBinding;
if (disableBinding) {
profile.disableCommands = g910DisableCommands();
@@ -74,23 +96,67 @@ const DeviceProfile* find_profile(uint16_t vendor, uint16_t product) {
return nullptr;
}
int match_gkey(const DeviceProfile& profile, const uint8_t* data, int length) {
if (length <= 0) {
return -1;
bool decode_gkey_state(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& pressed) {
pressed = 0;
if (data == nullptr || length <= 0 || profile.gKeyReports.empty()) {
return false;
}
for (size_t i = 0; i < profile.gKeyReports.size(); ++i) {
const Report& report = profile.gKeyReports[i];
if (static_cast<int>(report.size()) == length &&
std::equal(report.begin(), report.end(), data)) {
return static_cast<int>(i);
const Report& first = profile.gKeyReports.front();
if (static_cast<int>(first.size()) != length) {
return false; // a different report family, e.g. the 2-byte media keys
}
// Bytes that are the same in every key report are that model's fixed header;
// the bytes after it are the key bitmap (G1..G8 in byte 4, G9 in byte 5).
size_t header = 0;
for (; header < first.size(); ++header) {
bool shared = true;
for (const Report& report : profile.gKeyReports) {
if (report.size() != first.size() || report[header] != first[header]) {
shared = false;
break;
}
}
if (!shared) {
break;
}
}
return -1;
if (header >= first.size()) {
return false; // no bit to read: pressed and released cannot be told apart
}
for (size_t i = 0; i < header; ++i) {
if (data[i] != first[i]) {
return false; // disable/enable G-key command, memory key, LED answer
}
}
for (size_t key = 0; key < profile.gKeyReports.size() && key < 32; ++key) {
const Report& press = profile.gKeyReports[key];
for (size_t i = header; i < press.size() && i < static_cast<size_t>(length); ++i) {
if (press[i] != 0 && (data[i] & press[i]) == press[i]) {
pressed |= 1u << key;
break;
}
}
}
return true;
}
bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length) {
return static_cast<int>(profile.releaseReport.size()) == length &&
std::equal(profile.releaseReport.begin(), profile.releaseReport.end(), data);
bool decode_media_key(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& bits) {
bits = 0;
if (data == nullptr || profile.mediaPrefix == 0 || length < 2) {
return false;
}
if (data[0] != profile.mediaPrefix) {
return false; // a macro-key report, a memory key, a command or LED answer
}
// The report is two bytes; a longer read would be the same report padded by
// the endpoint, so only the bit byte is of interest.
bits = data[1];
return true;
}
} // namespace gkeymapper::daemon

View File

@@ -14,6 +14,14 @@ namespace gkeymapper::daemon {
// A raw HID report from the vendor interface.
using Report = std::vector<uint8_t>;
// One media key: the bit it has in the media report and the Linux input code it
// is replayed as.
struct MediaKey {
uint8_t bit = 0;
uint16_t key = 0; // Linux KEY_* code
const char* name = ""; // for the log line
};
struct DeviceProfile {
const char* name = "";
uint16_t vendor = 0;
@@ -22,9 +30,18 @@ struct DeviceProfile {
int interfaceNumber = 1;
int altSetting = 0;
// gKeyReports[i] is the press report for G(i+1).
// Media keys (mute / volume / play) arrive as a short report: this prefix
// byte followed by one bit per key, 0 when they are all released. 0 means
// the model reports none. This is the only path the keyboard offers for
// those keys, so while the daemon holds the interface the kernel does not see
// them at all — the daemon replays them through its virtual keyboard.
uint8_t mediaPrefix = 0;
std::vector<MediaKey> mediaKeys;
// gKeyReports[i] is the report that has the bit of G(i+1) set. Both the
// shared header bytes and the bit of each key are derived from these, so a
// new model only needs its table (see decode_gkey_state).
std::vector<Report> gKeyReports;
Report releaseReport;
// Some models must be told to stop mapping G-keys to F-keys first.
bool disableBinding = false;
@@ -36,11 +53,24 @@ const std::vector<DeviceProfile>& profiles();
// Matching profile for vendor/product, or nullptr.
const DeviceProfile* find_profile(uint16_t vendor, uint16_t product);
// Index of the G key (0..N-1) for a report, or -1 if it is not a known press.
int match_gkey(const DeviceProfile& profile, const uint8_t* data, int length);
// Decodes a macro-key report into the keys it reports as pressed: bit 0 = G1 ..
// bit 8 = G9. Returns false for every other report — the media keys have their
// own decoder below, and memory keys, LED and command acknowledgements are
// ignored on purpose.
//
// The report carries the state of all G keys, not a single key: one report with
// several bits set means a chord, and "no bit set" is the release. The caller
// compares consecutive states, which makes a held key (repeating report) fire
// once and a chord fire every key it contains.
bool decode_gkey_state(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& pressed);
// True if the report is the "all macro keys released" report.
bool is_release_report(const DeviceProfile& profile, const uint8_t* data, int length);
// Decodes a media-key report into its bits (bit set = key held), using the bit
// table of the profile. `bits` is 0 for the release report, which is a valid
// media report. Returns false for every other report, in particular for the
// macro-key reports.
bool decode_media_key(const DeviceProfile& profile, const uint8_t* data, int length,
uint32_t& bits);
} // namespace gkeymapper::daemon

View File

@@ -16,7 +16,7 @@ GKeyDevice::~GKeyDevice() {
close();
}
bool GKeyDevice::open() {
bool GKeyDevice::open(bool quiet) {
if (handle_) {
return true;
}
@@ -47,15 +47,19 @@ bool GKeyDevice::open() {
}
if (!target) {
std::fprintf(stderr, "gkeymapper-daemon: no supported keyboard found\n");
if (!quiet) {
std::fprintf(stderr, "gkeymapper-daemon: no supported keyboard found\n");
}
libusb_free_device_list(devices, 1);
close();
return false;
}
if (libusb_open(target, &handle_) != 0) {
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n",
profile_->name);
if (!quiet) {
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n",
profile_->name);
}
libusb_free_device_list(devices, 1);
close();
return false;
@@ -74,8 +78,10 @@ bool GKeyDevice::open() {
}
}
if (libusb_claim_interface(handle_, profile_->interfaceNumber) != 0) {
std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n",
profile_->interfaceNumber);
if (!quiet) {
std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n",
profile_->interfaceNumber);
}
close();
return false;
}

View File

@@ -21,7 +21,11 @@ public:
// Finds a supported keyboard, opens it and detaches the kernel driver from
// the vendor interface. Returns false (and logs) if none is found.
bool open();
//
// `quiet` silences the "cannot use the keyboard" lines only, which the
// hot-plug retry uses so an unplugged keyboard does not flood the journal
// with one line every retry; libusb-level failures always log.
bool open(bool quiet = false);
void close();
bool is_open() const { return handle_ != nullptr; }

View File

@@ -4,8 +4,8 @@
// configured action and replays it ("key" via uinput, "command" via exec).
//
// Milestones: M0 (shared core), M1 (device bring-up/decoding), M2 (action
// execution), M3 (config watching + lifecycle) and M4 (D-Bus GUI channel) are
// done.
// execution), M3 (config watching + lifecycle), M4 (D-Bus GUI channel) and
// M5 (hot-plug, repeat/chord decoding, error reporting to the GUI) are done.
#include <csignal>
#include <cstdint>
@@ -33,6 +33,12 @@ void handle_reload(int /*signum*/) {
g_reload = 1;
}
// How often the daemon looks for the keyboard while it has none. The loop keeps
// pumping D-Bus and the config watch in between, so the control plane stays
// responsive and GetStatus can report that there is no device.
constexpr gint64 kReopenIntervalUs = 2 * 1000 * 1000;
constexpr int kReopenSleepMs = 200;
} // namespace
int main(int argc, char** argv) {
@@ -126,14 +132,26 @@ int main(int argc, char** argv) {
break;
}
if (!device.open()) {
return 1;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
// Opens the keyboard and tells the GUI. Returns false when there is none,
// which is not an error: the session can start before the keyboard is
// plugged, and a lost or re-plugged keyboard must not cost the bus name —
// the GUI would see the daemon as gone and D-Bus activation would start a
// second one for nothing.
bool missingReported = false;
auto open_device = [&](bool quiet) -> bool {
if (!device.open(quiet)) {
return false;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
missingReported = false;
return true;
};
uint8_t buffer[64];
uint32_t pressed = 0; // G keys reported as pressed by the previous report
gint64 next_open = 0; // earliest time to look for the keyboard again
while (g_running) {
// Control plane first (bounded by the read timeout below), then the
// keyboard. The loop wakes up at least every read timeout, which also
@@ -145,34 +163,88 @@ int main(int argc, char** argv) {
reload();
}
if (!device.is_open()) {
const gint64 now = g_get_monotonic_time();
if (now >= next_open) {
next_open = now + kReopenIntervalUs;
// The first attempt of an absence explains itself, the retries
// stay quiet: one line per unplug instead of one per retry.
if (open_device(missingReported)) {
continue; // read from it right away
}
if (!missingReported) {
missingReported = true;
std::fprintf(stderr,
"gkeymapper-daemon: no usable keyboard, will keep "
"looking\n");
}
}
// Sleep briefly instead of the 500 ms read timeout, so a keyboard
// that appears is picked up sooner and the loop still services the
// control plane.
g_usleep(kReopenSleepMs * 1000);
continue;
}
int n = device.read_report(buffer, sizeof(buffer), 500);
if (n == 0) {
continue; // no report within the timeout
}
if (n < 0) {
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), reopening\n", n);
// Lost the keyboard (unplugged, or the kernel took the interface
// back). Report it and keep looking; the daemon stays up.
std::fprintf(stderr, "gkeymapper-daemon: read error (%d), device lost\n", n);
service.notify_device_changed(false, profile != nullptr ? profile->name : "");
device.close();
if (!device.open()) {
return 1;
}
device.disable_binding();
profile = device.profile();
service.notify_device_changed(true, profile != nullptr ? profile->name : "");
profile = nullptr;
pressed = 0;
next_open = 0;
continue;
}
int index = gkeymapper::daemon::match_gkey(*profile, buffer, n);
if (index >= 0 && index < static_cast<int>(mappings.keys.size())) {
run_mapping(index, false);
const gkeymapper::Mapping& mapping = mappings.keys[index];
service.notify_key_pressed(
index, std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target);
} else if (gkeymapper::daemon::is_release_report(*profile, buffer, n)) {
// Key released; nothing to do.
if (profile == nullptr) {
continue;
}
uint32_t state = 0;
if (gkeymapper::daemon::decode_gkey_state(*profile, buffer, n, state)) {
// Only a key that was not pressed before fires: a held key repeats its
// report, and a chord arrives as one report with several bits set, so
// every key of it fires exactly once.
for (size_t i = 0; i < mappings.keys.size() && i < gkeymapper::kNumKeys; ++i) {
const uint32_t bit = 1u << i;
if ((state & bit) == 0 || (pressed & bit) != 0) {
continue;
}
const gkeymapper::Mapping& mapping = mappings.keys[i];
const std::string action =
std::string(gkeymapper::action_type_token(mapping.type)) + ":" +
mapping.target;
run_mapping(static_cast<int>(i), false);
service.notify_key_pressed(static_cast<int>(i), action);
}
pressed = state;
continue;
}
// Media keys (mute / volume / play) are forwarded unchanged: they are
// reported only on the interface the daemon holds, so without this they
// would do nothing at all while it runs (see README). Replaying every
// report the keyboard sends keeps the behaviour identical to a keyboard
// the kernel drives itself — including its release report, which carries
// no bit and is therefore dropped below.
uint32_t media = 0;
if (!gkeymapper::daemon::decode_media_key(*profile, buffer, n, media) ||
media == 0) {
continue; // memory key, LED or command acknowledgement, or a release
}
for (const gkeymapper::daemon::MediaKey& key : profile->mediaKeys) {
if ((media & key.bit) == 0) {
continue;
}
std::printf("media -> %s\n", key.name);
std::fflush(stdout);
actions.tap_key(key.key);
}
// Media keys and LED acknowledgements are ignored.
}
service.notify_device_changed(false, profile != nullptr ? profile->name : "");

View File

@@ -99,6 +99,17 @@ void UinputKeyboard::key_event(unsigned short code, bool down) {
emit(EV_KEY, code, down ? 1 : 0);
}
void UinputKeyboard::tap(unsigned short code) {
if (fd_ < 0) {
return;
}
key_event(code, true);
sync();
usleep(2000);
key_event(code, false);
sync();
}
void UinputKeyboard::play(const std::vector<gkeymapper::ShortcutStep>& steps) {
if (fd_ < 0) {
return;

View File

@@ -25,6 +25,11 @@ public:
// Presses and releases every step of the shortcut in order.
void play(const std::vector<gkeymapper::ShortcutStep>& steps);
// Presses and releases a single Linux key code. The media keys are replayed
// with this: the keyboard reports them on its own (see README), so the daemon
// passes them on unchanged instead of turning them into a mapping.
void tap(unsigned short code);
private:
void emit(unsigned short type, unsigned short code, int value);
void sync();