diff --git a/src/hhd/controller/base.py b/src/hhd/controller/base.py index 5fb19bdb..aea03ba7 100644 --- a/src/hhd/controller/base.py +++ b/src/hhd/controller/base.py @@ -1259,6 +1259,37 @@ def process(self, events: Sequence[Event]) -> Sequence[Event]: if self.emit: self.emit({"type": "special", "event": "overlay"}) + if ev["code"] in ("hhd_qam", "hhd_expanded"): + # Direct binds for the HHD side menu (QAM) and the + # expanded HHD overlay, used by per-button remaps. + # Action names match shortcuts.yml. Emitted straight + # away so they bypass the multi-tap QAM state machine. + # "overlay" -> open_qam (side menu), "qam_triple" -> + # open_expanded (full overlay). + if ev["value"] and self.emit: + self.emit( + { + "type": "special", + "event": ( + "overlay" + if ev["code"] == "hhd_qam" + else "qam_triple" + ), + } + ) + ev["code"] = "" # type: ignore + + if ev["code"] in ("steam_qam", "steam_expanded"): + # Open Steam's own QAM / expanded menu via the same + # path Steam Deck uses. Handled at the end of process() + # so it respects intercept and the chord fallbacks. + if ev["value"]: + if ev["code"] == "steam_qam": + send_steam_qam = True + else: + send_steam_expand = True + ev["code"] = "" # type: ignore + if ev["code"] == "touchpad_right": match self.touchpad_right: case "disabled": diff --git a/src/hhd/controller/physical/evdev.py b/src/hhd/controller/physical/evdev.py index c26a7067..52135697 100644 --- a/src/hhd/controller/physical/evdev.py +++ b/src/hhd/controller/physical/evdev.py @@ -515,6 +515,83 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: return out +class ChordGamepadEvdev(GenericGamepadEvdev): + """A key-only ``GenericGamepadEvdev`` where a single physical key resolves + to one of two actions depending on whether a modifier key is held when it + is pressed. + + Used by the AYANEO Konkr, whose right-top and konkr (right-bottom-left) + buttons both emit ``KEY_F23`` -- the right-top button just also holds Ctrl. + The resolved action is latched on press so the release matches even if the + modifier is released first. Pass ``mod_btn``/``plain_btn`` as ``None`` to + leave that combination unmapped. Every other key uses the normal btn_map. + """ + + def __init__( + self, + *args, + mod_code: int, + plain_code: int, + mod_btn: Button | None, + plain_btn: Button | None, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.mod_code = mod_code + self.plain_code = plain_code + self.mod_btn = mod_btn + self.plain_btn = plain_btn + self.mod_held = False + self.chord_active: Button | None = None + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + if not self.dev or self.fd not in fds: + return [] + + out: list[Event] = [] + while can_read(self.fd): + for e in self.dev.read(): + if e.type != B("EV_KEY"): + continue + if e.code == self.mod_code: + # Track the modifier but never emit it directly. + self.mod_held = e.value != 0 + continue + if e.code == self.plain_code: + if e.value == 1: + self.chord_active = ( + self.mod_btn if self.mod_held else self.plain_btn + ) + if self.chord_active is not None: + out.append( + { + "type": "button", + "code": self.chord_active, + "value": True, + } + ) + elif e.value == 0: + if self.chord_active is not None: + out.append( + { + "type": "button", + "code": self.chord_active, + "value": False, + } + ) + self.chord_active = None + continue + if e.code in self.btn_map and e.value in (0, 1): + out.append( + { + "type": "button", + "code": self.btn_map[e.code], + "value": bool(e.value), + } + ) + return out + + _kbd_raw: dict[KeyboardButton, Sequence[int]] = { "key_esc": [B("KEY_ESC")], # 1 "key_enter": [B("KEY_ENTER")], # 28 @@ -687,4 +764,11 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]: KEYBOARD_MAP: dict[int, KeyboardButton] = to_map(_kbd_raw) -__all__ = ["GenericGamepadEvdev", "XBOX_BUTTON_MAP", "XBOX_AXIS_MAP", "B", "to_map"] +__all__ = [ + "GenericGamepadEvdev", + "ChordGamepadEvdev", + "XBOX_BUTTON_MAP", + "XBOX_AXIS_MAP", + "B", + "to_map", +] diff --git a/src/hhd/device/ayaneo/KONKR.md b/src/hhd/device/ayaneo/KONKR.md new file mode 100644 index 00000000..8efee4bd --- /dev/null +++ b/src/hhd/device/ayaneo/KONKR.md @@ -0,0 +1,127 @@ +# AYANEO Konkr Fit (HX470) — controller notes + +Reference for the Konkr Fit's controller hardware and how HHD maps it. Keep +this in sync if the firmware or mappings change. + +## Identification + +- DMI `product_name` = `KONKR FIT` (matched in `const.py` → `CONFS`). +- Konkr is an AYANEO sub-brand; the controller hardware is the **same as the + AYANEO 3**: + - `1c4f:0002` — AYANEO COMPOSITE DEVICE (`AYA_VID`/`AYA_PID`). Carries the + extra buttons and the right-side system buttons as keyboard keys. + - `045e:028e` — Xbox-style gamepad (`GAMEPAD_VID`/`GAMEPAD_PID`). Carries the + sticks, triggers, ABXY, d-pad, and the three left-side system buttons. +- Unlike the AYANEO 3 it has **no detachable modules** (no `ayaneo-ec` EC + sysfs interface), so `magic_modules` is off. + +## Physical button layout + +Six "system / face" buttons surround the screen, plus four extra buttons +(two rear paddles + two LC/RC buttons). Looking at the device face-on: + +``` + LEFT module RIGHT module + [ left-top ] [ right-top ] + [ L-sel ][ L-start ] [ konkr ][ R-bottom ] +``` + +- **LC / RC** = the two inner buttons under each grip. +- **rear-left / rear-right** = the two back paddles. + +## Raw evdev codes (captured on-device) + +| # | Physical button | Device | Sends (evdev) | +|---|-----------------------|------------------|--------------------------| +| 1 | left-top | gamepad (028e) | `BTN_MODE` (316) | +| 2 | left-bottom-left | gamepad (028e) | `BTN_SELECT` (314) | +| 3 | left-bottom-right | gamepad (028e) | `BTN_START` (315) | +| 4 | right-top | composite (0002) | `Ctrl` (29) + `F23` (193)| +| 5 | konkr (right-btm-left)| composite (0002) | `F23` (193) alone | +| 6 | right-bottom-right | composite (0002) | `Meta` (125) + `D` (32) | +| - | LC | composite (0002) | `F21` (191) | +| - | RC | composite (0002) | `F22` (192) | +| - | rear-left paddle | composite (0002) | `KEY_L` (38) | +| - | rear-right paddle | composite (0002) | `KEY_R` (19) | + +> **Important gotcha:** right-top (#4) and konkr (#5) emit the **same** key +> (`F23`). right-top just also holds `Ctrl`. A plain one-code → one-action map +> can't tell them apart — see the chord handling below. + +## How HHD maps them + +### Extra buttons (LC/RC + rear paddles) +`extra_buttons: "quad"`. These map to `extra_l1/l2/r1/r2` and are exposed as +DualSense Edge / Xbox Elite back paddles — remappable in **Steam Input**: + +| Button | evdev | Output | +|------------|---------|------------| +| rear-left | `KEY_L` | `extra_l1` | +| rear-right | `KEY_R` | `extra_r1` | +| LC | `F21` | `extra_l2` | +| RC | `F22` | `extra_r2` | + +### The six system/face buttons — "Konkr Button Map" +Enabled by the `face_remap` flag in `const.py`. Each button gets a dropdown +(`konkr_buttons.yml`, injected in `__init__.py:settings()`), applied in +`base.py`. Values/labels mirror `plugins/overlay/shortcuts.yml` (HHD's +official vocabulary): + +| Dropdown value | Label | Result | +|------------------|-----------------|--------| +| `disabled` | Disabled | Button does nothing | +| `mode` | Steam / Guide | Opens Steam (guide button) | +| `select` | Select (View) | gamepad Select/View | +| `start` | Start (Menu) | gamepad Start/Menu | +| `steam_qam` | Steam Side Menu | Opens Steam's own QAM | +| `steam_expanded` | Steam Overlay | Opens Steam's expanded menu | +| `hhd_qam` | HHD Side Menu | Opens the HHD QAM side menu (`open_qam`) | +| `hhd_expanded` | HHD Overlay | Opens the expanded HHD overlay (`open_expanded`) | + +`mode`/`select`/`start` are plain gamepad buttons. The four menu actions are +not real gamepad buttons -- they are sentinel codes handled in +`controller/base.py` (the multiplexer), which clears the code so it never +reaches the virtual controller: + +- `hhd_qam` -> emits `special` event `overlay` -> `open_qam` (HHD side menu). +- `hhd_expanded` -> emits `special` event `qam_triple` -> `open_expanded`. + Both bypass the multi-tap QAM state machine, so they are deterministic. +- `steam_qam` / `steam_expanded` -> set `send_steam_qam` / `send_steam_expand`, + so the multiplexer opens Steam's menu through the normal Steam path at the + end of `process()` (intercept-aware, with the guide+A chord fallback if + gamescope isn't handling it). + +> The standard **Aya Button Map** (`swap_guide`) dropdown is hidden on the +> Konkr (removed in `__init__.py:settings()`), since this per-button map +> supersedes it. With it gone, `base.py` falls back to `swap_guide="oem"` +> (no swap), which is correct for the custom map. + +| Dropdown key | Physical button | Source code | Default | +|--------------------|-------------------|----------------|------------| +| `btn_left_top` | left-top | `BTN_MODE` | `mode` | +| `btn_left_select` | left-bottom-left | `BTN_SELECT` | `select` | +| `btn_left_start` | left-bottom-right | `BTN_START` | `start` | +| `btn_right_top` | right-top | `Ctrl`+`F23` | `disabled` | +| `btn_konkr` | konkr | `F23` | `disabled` | +| `btn_right_bottom` | right-bottom-right| `KEY_D` | `share` | + +- Left-side three go through the gamepad: `base.py` overrides + `XBOX_BUTTON_MAP` for `BTN_MODE/SELECT/START`; `disabled` drops the key. +- Right-side three go through the composite keyboard: + - `KEY_D` → `btn_right_bottom`. + - `F23` is split by **`ChordGamepadEvdev`** (`evdev.py`): if `Ctrl` is held + it resolves to `btn_right_top`, otherwise `btn_konkr`. The action is + **latched on press** so the release matches even if `Ctrl` is let go + first (prevents stuck buttons). + +Defaults reproduce the pre-remap behavior, so existing users see no change +until they touch a dropdown. Everything is gated on `face_remap`, so the +AYANEO 3's identical gamepad buttons are never affected. + +## Files + +- `const.py` — `CONFS["KONKR FIT"]` device entry + flags. +- `konkr_buttons.yml` — the six dropdowns (UI schema). +- `__init__.py` — injects the schema when `face_remap` is set. +- `base.py` — reads the dropdowns and builds the evdev button maps. +- `../../controller/physical/evdev.py` — `ChordGamepadEvdev`. diff --git a/src/hhd/device/ayaneo/__init__.py b/src/hhd/device/ayaneo/__init__.py index 8d4dc60e..a69b9c20 100644 --- a/src/hhd/device/ayaneo/__init__.py +++ b/src/hhd/device/ayaneo/__init__.py @@ -70,6 +70,13 @@ def settings(self) -> HHDSettings: ) ) + if self.dconf.get("face_remap", False): + children = base["controllers"]["ayaneo"]["children"] + children["face_buttons"] = load_relative_yaml("konkr_buttons.yml") + # The per-button map supersedes the Aya Button Map (swap_guide), + # so hide it to avoid two conflicting remap UIs. + children.pop("swap_guide", None) + if self.dconf.get("display_gyro", True): base["controllers"]["ayaneo"]["children"]["imu_axis"] = get_gyro_config( self.dconf.get("mapping", DEFAULT_MAPPINGS) diff --git a/src/hhd/device/ayaneo/base.py b/src/hhd/device/ayaneo/base.py index 42b60ab4..5690b9ee 100644 --- a/src/hhd/device/ayaneo/base.py +++ b/src/hhd/device/ayaneo/base.py @@ -8,7 +8,11 @@ from hhd.controller.lib.hide import unhide_all from hhd.controller.physical.evdev import XBOX_BUTTON_MAP from hhd.controller.physical.evdev import B as EC -from hhd.controller.physical.evdev import GenericGamepadEvdev, enumerate_evs +from hhd.controller.physical.evdev import ( + ChordGamepadEvdev, + GenericGamepadEvdev, + enumerate_evs, +) from hhd.controller.physical.hidraw import GenericGamepadHidraw from hhd.controller.physical.imu import CombinedImu, HrtimerTrigger from hhd.controller.virtual.uinput import UInputDevice @@ -509,13 +513,40 @@ def controller_loop( d_timer = HrtimerTrigger(conf["imu_hz"].to(int), [HrtimerTrigger.IMU_NAMES]) # Inputs + face_remap = dconf.get("face_remap", False) + + def remap_action(key: str, default: str): + # Resolve a Konkr Button Map dropdown into an output Button. The UI + # "disabled" option is returned as None so the key gets dropped. + val = conf.get(f"face_buttons.{key}", default) + return None if val == "disabled" else val + + # By default the gamepad's BTN_MODE is repurposed as the QAM/overlay + # button (share). On devices where it is the main "guide" button (e.g. + # Konkr Fit), keep it as the XBOX default (mode) so it opens Steam. + xinput_btn_map = {**XBOX_BUTTON_MAP} + if face_remap: + # The three left-side buttons come through the Xbox gamepad. Apply the + # user's per-button choices, dropping any set to "Disabled". + for code, key, default in ( + (EC("BTN_MODE"), "btn_left_top", "mode"), + (EC("BTN_SELECT"), "btn_left_select", "select"), + (EC("BTN_START"), "btn_left_start", "start"), + ): + act = remap_action(key, default) + if act is None: + xinput_btn_map.pop(code, None) + else: + xinput_btn_map[code] = act + elif not dconf.get("mode_is_guide", False): + xinput_btn_map[EC("BTN_MODE")] = "share" d_xinput = GenericGamepadEvdev( vid=[GAMEPAD_VID], pid=[GAMEPAD_PID], capabilities={EC("EV_KEY"): [EC("BTN_A")]}, required=True, hide=True, - btn_map={**XBOX_BUTTON_MAP, EC("BTN_MODE"): "share"}, + btn_map=xinput_btn_map, ) d_kbd_1 = GenericGamepadEvdev( @@ -527,21 +558,49 @@ def controller_loop( EC("KEY_F23"): "mode", }, ) - d_kbd_2 = GenericGamepadEvdev( - vid=[AYA_VID], - pid=[AYA_PID], - required=True, - grab=True, - capabilities={EC("EV_KEY"): [EC("KEY_F21")]}, - btn_map={ + if face_remap: + # The three right-side buttons come through the AYANEO composite + # keyboard. right-bottom-right is KEY_D; right-top (Ctrl+F23) and konkr + # (F23 alone) share the F23 keycode, so they are split by the chord + # producer using the Ctrl modifier. Paddles stay on the normal map. + kbd2_map = { EC("KEY_F24"): "keyboard", - EC("KEY_D"): "keyboard", EC("KEY_F21"): "extra_l2", EC("KEY_F22"): "extra_r2", EC("KEY_L"): "extra_l1", EC("KEY_R"): "extra_r1", - }, - ) + } + right_bottom = remap_action("btn_right_bottom", "hhd_qam") + if right_bottom is not None: + kbd2_map[EC("KEY_D")] = right_bottom + d_kbd_2 = ChordGamepadEvdev( + vid=[AYA_VID], + pid=[AYA_PID], + required=True, + grab=True, + capabilities={EC("EV_KEY"): [EC("KEY_F21")]}, + btn_map=kbd2_map, + mod_code=EC("KEY_LEFTCTRL"), + plain_code=EC("KEY_F23"), + mod_btn=remap_action("btn_right_top", "disabled"), + plain_btn=remap_action("btn_konkr", "disabled"), + ) + else: + d_kbd_2 = GenericGamepadEvdev( + vid=[AYA_VID], + pid=[AYA_PID], + required=True, + grab=True, + capabilities={EC("EV_KEY"): [EC("KEY_F21")]}, + btn_map={ + EC("KEY_F24"): "keyboard", + EC("KEY_D"): "keyboard", + EC("KEY_F21"): "extra_l2", + EC("KEY_F22"): "extra_r2", + EC("KEY_L"): "extra_l1", + EC("KEY_R"): "extra_r1", + }, + ) d_vend = Ayaneo3Hidraw( vid=[AYA_VID], pid=[AYA_PID], diff --git a/src/hhd/device/ayaneo/const.py b/src/hhd/device/ayaneo/const.py index 6db6fdd4..f256cd83 100644 --- a/src/hhd/device/ayaneo/const.py +++ b/src/hhd/device/ayaneo/const.py @@ -27,6 +27,22 @@ "rgb": True, **AYA_DEFAULT_CONF, }, + # Konkr (Ayaneo sub-brand). Same controller hw as AYANEO 3 + # (1c4f:0002 composite + 045e:028e gamepad) but no detachable + # modules (no ayaneo-ec EC interface). Four extra buttons (two rear + # paddles + LC/RC) and BTN_MODE is the main button, so it acts as the + # guide/Steam button rather than the QAM/overlay button. + "KONKR FIT": { + "name": "KONKR FIT", + "extra_buttons": "quad", + "mode_is_guide": True, + # Expose per-button remap dropdowns for the six system/face buttons + # (handled in base.py + konkr_buttons.yml). Gated so it never touches + # the gamepad BTN_MODE/SELECT/START on other AYANEO devices. + "face_remap": True, + "rgb": True, + **AYA_DEFAULT_CONF, + }, } AYA3_INIT = [ diff --git a/src/hhd/device/ayaneo/konkr_buttons.yml b/src/hhd/device/ayaneo/konkr_buttons.yml new file mode 100644 index 00000000..5f6e2139 --- /dev/null +++ b/src/hhd/device/ayaneo/konkr_buttons.yml @@ -0,0 +1,54 @@ +type: container +tags: [non-essential] +title: Konkr Button Map +hint: >- + Remap the six system/face buttons unique to the Konkr Fit. The sticks, + triggers and rear paddles stay remappable through Steam Input as usual. + +children: + btn_left_top: + type: multiple + title: Left Top + # Action values/labels mirror src/hhd/plugins/overlay/shortcuts.yml so the + # Konkr map uses HHD's official vocabulary. `mode`/`select`/`start` are the + # plain gamepad buttons; the rest are overlay/Steam shortcut actions. + options: &konkr_btn_options + disabled: Disabled + mode: Steam / Guide + select: Select (View) + start: Start (Menu) + steam_qam: Steam Side Menu + steam_expanded: Steam Overlay + hhd_qam: HHD Side Menu + hhd_expanded: HHD Overlay + default: mode + + btn_left_select: + type: multiple + title: Left Bottom-Left + options: *konkr_btn_options + default: select + + btn_left_start: + type: multiple + title: Left Bottom-Right + options: *konkr_btn_options + default: start + + btn_right_top: + type: multiple + title: Right Top + options: *konkr_btn_options + default: disabled + + btn_konkr: + type: multiple + title: Konkr (Right Bottom-Left) + options: *konkr_btn_options + default: disabled + + btn_right_bottom: + type: multiple + title: Right Bottom-Right + options: *konkr_btn_options + default: hhd_qam