feat(tv-input): gamepad->keyboard bridge daemon + controller-navigable modals
- archipelago-gamepad-keys: evdev->uinput daemon (pure python3 stdlib) that mirrors any attached controller as a virtual keyboard — D-pad/stick to arrows, A/B to Enter/Escape, X/Y to Space/f, shoulders to Tab/Shift+Tab. The browser sees real keys, so controllers work inside EVERY app iframe (IndeeHub, Jellyfin…) with zero per-app code. Ships via the ISO splice and bootstrap self-heal (kiosk nodes only), hotplug via 5s rescans. Implements docs/tv-input-iframe-apps.md. - useControllerNav: an open [role=dialog][aria-modal] owns navigation — focus is pulled inside, arrows move spatially between its controls, Enter activates, Escape stays with the modal's own close handling. One standard mapping for every modal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
402183c0ae
commit
54ddd043bd
@ -52,6 +52,15 @@ const AUDIO_SERVICE: &str =
|
||||
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||
|
||||
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
|
||||
// splice-from-configs + self-heal pattern as the audio router.
|
||||
const GAMEPAD_KEYS: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
|
||||
const GAMEPAD_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
|
||||
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
|
||||
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
@ -879,6 +888,37 @@ pub async fn ensure_audio_stack() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
|
||||
/// daemon (controller works inside every app iframe on the TV) installed and
|
||||
/// current. Same gating as audio: no kiosk → no display input to bridge.
|
||||
pub async fn ensure_gamepad_keys() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge installed and enabled (TV controller input)");
|
||||
} else if script_changed || unit_changed {
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
|
||||
@ -391,6 +391,10 @@ async fn main() -> Result<()> {
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
tokio::spawn(bootstrap::ensure_audio_stack());
|
||||
|
||||
// TV input: gamepad→keyboard bridge so controllers work inside every app
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||
|
||||
@ -2913,6 +2913,33 @@ fi
|
||||
echo "AUDIOSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
# Gamepad->keyboard bridge: same pattern (configs/ single source of truth,
|
||||
# also self-healed via the binary). TV input for every app iframe.
|
||||
GAMEPAD_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.py"
|
||||
GAMEPAD_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.service"
|
||||
for _pad_src in "$GAMEPAD_SRC" "$GAMEPAD_SERVICE_SRC"; do
|
||||
if [ ! -f "$_pad_src" ]; then
|
||||
echo "ERROR: gamepad config file missing: $_pad_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if grep -qx 'GAMEPADPY' "$GAMEPAD_SRC" || grep -qx 'GAMEPADSVC' "$GAMEPAD_SERVICE_SRC"; then
|
||||
echo "ERROR: gamepad config contains a reserved heredoc terminator line (GAMEPADPY/GAMEPADSVC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "# Gamepad->keyboard bridge — controller input inside every app iframe on the TV."
|
||||
echo "cat > /mnt/target/usr/local/bin/archipelago-gamepad-keys <<'GAMEPADPY'"
|
||||
cat "$GAMEPAD_SRC"
|
||||
echo "GAMEPADPY"
|
||||
echo "chmod +x /mnt/target/usr/local/bin/archipelago-gamepad-keys"
|
||||
echo ""
|
||||
echo "cat > /mnt/target/etc/systemd/system/archipelago-gamepad-keys.service <<'GAMEPADSVC'"
|
||||
cat "$GAMEPAD_SERVICE_SRC"
|
||||
echo "GAMEPADSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
||||
|
||||
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
||||
@ -3293,6 +3320,7 @@ chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null ||
|
||||
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-gamepad-keys.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
|
||||
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
|
||||
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \
|
||||
|
||||
223
image-recipe/configs/archipelago-gamepad-keys.py
Normal file
223
image-recipe/configs/archipelago-gamepad-keys.py
Normal file
@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
|
||||
|
||||
Reads every attached game controller via evdev and mirrors it as a virtual
|
||||
uinput KEYBOARD, so gamepad input works in every app — including cross-origin
|
||||
iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events.
|
||||
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
|
||||
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
|
||||
|
||||
Mapping (standard pad):
|
||||
D-pad / left stick -> arrow keys
|
||||
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
|
||||
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
|
||||
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
|
||||
Start -> Enter Select -> Escape
|
||||
|
||||
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way
|
||||
one is play/pause and one is fullscreen, which is fine for a TV.
|
||||
|
||||
Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node.
|
||||
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
|
||||
# ---- kernel constants ------------------------------------------------------
|
||||
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
|
||||
SYN_REPORT = 0
|
||||
|
||||
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
|
||||
KEY_LEFTSHIFT, KEY_F = 42, 33
|
||||
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
|
||||
|
||||
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
|
||||
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
|
||||
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
|
||||
|
||||
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
|
||||
|
||||
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
|
||||
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
|
||||
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
|
||||
|
||||
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
|
||||
|
||||
BUTTON_MAP = {
|
||||
BTN_SOUTH: (KEY_ENTER,),
|
||||
BTN_EAST: (KEY_ESC,),
|
||||
BTN_NORTH: (KEY_SPACE,),
|
||||
BTN_WEST: (KEY_F,),
|
||||
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
|
||||
BTN_TR: (KEY_TAB,),
|
||||
BTN_START: (KEY_ENTER,),
|
||||
BTN_SELECT: (KEY_ESC,),
|
||||
BTN_DPAD_UP: (KEY_UP,),
|
||||
BTN_DPAD_DOWN: (KEY_DOWN,),
|
||||
BTN_DPAD_LEFT: (KEY_LEFT,),
|
||||
BTN_DPAD_RIGHT: (KEY_RIGHT,),
|
||||
}
|
||||
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
|
||||
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
|
||||
|
||||
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
|
||||
|
||||
|
||||
def is_gamepad(fd) -> bool:
|
||||
buf = bytearray(96)
|
||||
try:
|
||||
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
|
||||
except OSError:
|
||||
return False
|
||||
def has(code):
|
||||
return bool(buf[code // 8] & (1 << (code % 8)))
|
||||
return has(BTN_SOUTH) or has(BTN_START)
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
def __init__(self):
|
||||
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
||||
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||||
for key in EMITTED_KEYS:
|
||||
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
|
||||
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
|
||||
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
|
||||
name = b"Archipelago Gamepad Keys"
|
||||
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
|
||||
setup += b"\0" * (64 * 4 * 4)
|
||||
os.write(self.fd, setup)
|
||||
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||
|
||||
def _emit(self, etype, code, value):
|
||||
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
|
||||
|
||||
def set_key(self, key, pressed):
|
||||
self._emit(EV_KEY, key, 1 if pressed else 0)
|
||||
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||
|
||||
def chord(self, keys, pressed):
|
||||
seq = keys if pressed else tuple(reversed(keys))
|
||||
for k in seq:
|
||||
self._emit(EV_KEY, k, 1 if pressed else 0)
|
||||
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||
|
||||
|
||||
class PadState:
|
||||
"""Per-device axis state → synthetic arrow presses."""
|
||||
|
||||
def __init__(self):
|
||||
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
|
||||
self.abs_range = {} # axis -> (min, max) for sticks
|
||||
|
||||
def arrow_for(self, axis, value):
|
||||
if axis in (ABS_HAT0X, ABS_HAT0Y):
|
||||
if value < 0:
|
||||
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
|
||||
if value > 0:
|
||||
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
|
||||
return None
|
||||
lo, hi = self.abs_range.get(axis, (-32768, 32767))
|
||||
span = (hi - lo) or 1
|
||||
norm = (2 * (value - lo) / span) - 1
|
||||
if norm <= -STICK_THRESHOLD:
|
||||
return KEY_LEFT if axis == ABS_X else KEY_UP
|
||||
if norm >= STICK_THRESHOLD:
|
||||
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
|
||||
return None
|
||||
|
||||
|
||||
def stick_range(fd, axis):
|
||||
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
|
||||
buf = bytearray(24)
|
||||
try:
|
||||
fcntl.ioctl(fd, 0x80184540 + axis, buf)
|
||||
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
|
||||
if hi > lo:
|
||||
return (lo, hi)
|
||||
except OSError:
|
||||
pass
|
||||
return (-32768, 32767)
|
||||
|
||||
|
||||
def main():
|
||||
os.system("modprobe uinput 2>/dev/null")
|
||||
kbd = VirtualKeyboard()
|
||||
pads = {} # path -> (fd, PadState)
|
||||
last_scan = 0.0
|
||||
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if now - last_scan > 5:
|
||||
last_scan = now
|
||||
try:
|
||||
names = sorted(os.listdir("/dev/input"))
|
||||
except FileNotFoundError:
|
||||
names = []
|
||||
for name in names:
|
||||
if not name.startswith("event"):
|
||||
continue
|
||||
path = "/dev/input/" + name
|
||||
if path in pads:
|
||||
continue
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
continue
|
||||
if is_gamepad(fd):
|
||||
state = PadState()
|
||||
for axis in (ABS_X, ABS_Y):
|
||||
state.abs_range[axis] = stick_range(fd, axis)
|
||||
pads[path] = (fd, state)
|
||||
print(f"gamepad attached: {path}", flush=True)
|
||||
else:
|
||||
os.close(fd)
|
||||
|
||||
if not pads:
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
|
||||
gone = []
|
||||
for path, (fd, state) in list(pads.items()):
|
||||
if fd not in readable:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, INPUT_EVENT.size * 64)
|
||||
except OSError:
|
||||
gone.append(path)
|
||||
continue
|
||||
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
|
||||
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||||
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
|
||||
keys = BUTTON_MAP[code]
|
||||
if len(keys) == 1:
|
||||
kbd.set_key(keys[0], value == 1)
|
||||
else:
|
||||
kbd.chord(keys, value == 1)
|
||||
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
|
||||
want = state.arrow_for(code, value)
|
||||
held = state.axis_keys.get(code)
|
||||
if want != held:
|
||||
if held is not None:
|
||||
kbd.set_key(held, False)
|
||||
if want is not None:
|
||||
kbd.set_key(want, True)
|
||||
state.axis_keys[code] = want
|
||||
for path in gone:
|
||||
fd, state = pads.pop(path)
|
||||
for held in state.axis_keys.values():
|
||||
if held is not None:
|
||||
kbd.set_key(held, False)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"gamepad detached: {path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
image-recipe/configs/archipelago-gamepad-keys.service
Normal file
16
image-recipe/configs/archipelago-gamepad-keys.service
Normal file
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe)
|
||||
# Only meaningful where a display UI runs; the kiosk unit is the marker.
|
||||
ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service
|
||||
ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Root: /dev/uinput device creation + raw /dev/input readers.
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Nice=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
/** Topmost open modal dialog, if any — it owns navigation while visible. */
|
||||
function getOpenModal(): HTMLElement | null {
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
|
||||
).filter(el => el.offsetParent !== null)
|
||||
return dialogs[dialogs.length - 1] ?? null
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const target = e.target as HTMLElement
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// ── MODAL SCOPE ──────────────────────────────────────────
|
||||
// An open dialog owns navigation: focus is pulled inside, arrows move
|
||||
// spatially between its controls, Enter activates. Escape stays with the
|
||||
// modal's own close handling. Standard mapping, no per-modal code.
|
||||
const modal = getOpenModal()
|
||||
if (modal) {
|
||||
if (e.key === 'Escape') return
|
||||
const focusables = getFocusableElements(modal)
|
||||
if (!focusables.length) return
|
||||
if (!activeEl || !modal.contains(activeEl)) {
|
||||
e.preventDefault()
|
||||
const first = focusables[0]
|
||||
if (first) focusEl(first)
|
||||
return
|
||||
}
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
// Typing keys stay with the field; Enter keeps the form-submit
|
||||
// behavior below; only Up/Down leave the field spatially.
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const dir =
|
||||
e.key === 'ArrowDown' ? ('down' as const)
|
||||
: e.key === 'ArrowUp' ? ('up' as const)
|
||||
: e.key === 'ArrowLeft' ? ('left' as const)
|
||||
: ('right' as const)
|
||||
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
return
|
||||
}
|
||||
|
||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user