Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/hhd/controller/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
86 changes: 85 additions & 1 deletion src/hhd/controller/physical/evdev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
]
127 changes: 127 additions & 0 deletions src/hhd/device/ayaneo/KONKR.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions src/hhd/device/ayaneo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
83 changes: 71 additions & 12 deletions src/hhd/device/ayaneo/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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],
Expand Down
Loading