/* * jcmapper - Nintendo Switch Joy-Con -> virtual mouse+keyboard (uinput) * * Custom control scheme (supersedes the official Switchroot L4T table): * * LEFT Joy-Con RIGHT Joy-Con * ------------------------------ ------------------------------ * ZL / LT -> right click ZR / RT -> left click * L / LB -> Page Up R / RB -> Page Down * Minus -> rotate screen Plus -> toggle on-screen kbd * L3 -> Caps Lock X -> Escape * Screenshot -> enable/disable all A -> Enter * L-stick -> mouse motion B -> Backspace * D-pad -> arrow keys Y -> task switcher (Alt+Tab) * R3 -> middle click * R-stick -> scrolling (wheel) * Home -> meta key (Super) * * Screenshot and Plus work even while the mapping is disabled, so the * mapping can always be turned back on and the OSK always reachable. * * Why userspace: xf86-input-joystick no longer exists in Arch, and X's * evdev driver treats a spring-return analog stick as an ABSOLUTE pointer, * so releasing it teleports the cursor to screen centre. Emitting REL_X/ * REL_Y with a deadzone fixes that. * * Two uinput devices are created (mouse / keyboard) because X attaches a * slave to exactly one master, so a combined device would only reach one. * The mouse device also advertises REL_WHEEL/REL_HWHEEL for R-stick scrolling. * * Usage: jcmapper [-v] (-v dumps raw events, for verifying mappings) */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include #include #define JC_LEFT_NAME "Nintendo Switch Left Joy-Con Serial" #define JC_RIGHT_NAME "Nintendo Switch Right Joy-Con Serial" #define MOUSE_NAME "ALARM JoyCon Mouse" #define KBD_NAME "ALARM JoyCon Keyboard" #define DEADZONE 5000 /* out of 32767 */ #define MAXPX 10 /* max pixels per 8ms tick at full deflection. * 14 was too sensitive at full tilt (~1750 px/s); * 10 gives ~1250 px/s while keeping the fine * low-deflection control from the quadratic curve. */ #define TICK_MS 8 static int mouse_fd = -1, kbd_fd = -1; static int verbose = 0; static int enabled = 1; /* Screenshot toggles this */ static int lock_fd = -1; /* single-instance lock */ static pid_t osk_pid = 0; /* on-screen keyboard child, if running */ /* fractional wheel accumulation for R-stick scrolling */ static double wheel_acc_y = 0.0, wheel_acc_x = 0.0; /* fractional pixel accumulation for L-stick motion */ static double axis_acc_x = 0.0, axis_acc_y = 0.0; static int iabs(int v) { return v < 0 ? -v : v; } /* ---------------- uinput setup ---------------- */ static int uinput_create(const char *name, int with_rel, int with_kbd) { int fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK); if (fd < 0) { fprintf(stderr, "open /dev/uinput: %s\n", strerror(errno)); return -1; } ioctl(fd, UI_SET_EVBIT, EV_KEY); if (with_rel) { ioctl(fd, UI_SET_EVBIT, EV_REL); ioctl(fd, UI_SET_RELBIT, REL_X); ioctl(fd, UI_SET_RELBIT, REL_Y); ioctl(fd, UI_SET_RELBIT, REL_WHEEL); /* R-stick vertical scroll */ ioctl(fd, UI_SET_RELBIT, REL_HWHEEL); /* R-stick horizontal */ ioctl(fd, UI_SET_KEYBIT, BTN_LEFT); ioctl(fd, UI_SET_KEYBIT, BTN_RIGHT); ioctl(fd, UI_SET_KEYBIT, BTN_MIDDLE); } if (with_kbd) { /* Broad key range so X reliably classifies this as a keyboard. */ int k; for (k = 1; k < 256; k++) ioctl(fd, UI_SET_KEYBIT, k); } struct uinput_user_dev u; memset(&u, 0, sizeof u); snprintf(u.name, UINPUT_MAX_NAME_SIZE, "%s", name); u.id.bustype = BUS_VIRTUAL; u.id.vendor = 0x057e; /* Nintendo */ u.id.product = with_rel ? 0x1001 : 0x1002; u.id.version = 1; if (write(fd, &u, sizeof u) != (ssize_t)sizeof u) { fprintf(stderr, "uinput write: %s\n", strerror(errno)); close(fd); return -1; } if (ioctl(fd, UI_DEV_CREATE) < 0) { fprintf(stderr, "UI_DEV_CREATE: %s\n", strerror(errno)); close(fd); return -1; } return fd; } static void emit(int fd, unsigned short type, unsigned short code, int value) { struct input_event e; if (fd < 0) return; memset(&e, 0, sizeof e); e.type = type; e.code = code; e.value = value; if (write(fd, &e, sizeof e) != (ssize_t)sizeof e) fprintf(stderr, "emit write: %s\n", strerror(errno)); } static void send_sync(int fd) { emit(fd, EV_SYN, SYN_REPORT, 0); } static void kbd_key(int code, int val) { emit(kbd_fd, EV_KEY, code, val); send_sync(kbd_fd); if (verbose) fprintf(stderr, "KBD %s %d -> %s\n", val ? "down" : "up", code, val ? "" : ""); } static void mouse_btn(int code, int val) { emit(mouse_fd, EV_KEY, code, val); send_sync(mouse_fd); } /* Toggle the on-screen keyboard: spawn it if not running, kill it if it is. * Child is started detached with DISPLAY/XAUTHORITY set because jcmapper is * launched from the shell-based init and inherits no X environment. */ static void osk_toggle(void) { if (osk_pid > 0) { if (kill(osk_pid, SIGTERM) == 0) fprintf(stderr, "jcmapper: on-screen keyboard closed\n"); waitpid(osk_pid, NULL, 0); osk_pid = 0; return; } pid_t p = fork(); if (p < 0) { fprintf(stderr, "jcmapper: fork failed: %s\n", strerror(errno)); return; } if (p == 0) { setsid(); setenv("DISPLAY", ":0", 1); setenv("XAUTHORITY", "/root/.Xauthority", 1); execl("/usr/local/bin/jcosk", "jcosk", (char *)NULL); _exit(127); } osk_pid = p; fprintf(stderr, "jcmapper: on-screen keyboard started (pid %d)\n", (int)p); } /* ---------------- control scheme ---------------- */ /* side: 0 = left Joy-Con, 1 = right Joy-Con */ /* press+release a key immediately (for one-shot actions) */ static void tap_key(int code) { kbd_key(code, 1); kbd_key(code, 0); } /* Y: Alt+Tab window switcher (OpenBox default binding) */ static void task_switcher(void) { kbd_key(KEY_LEFTALT, 1); kbd_key(KEY_TAB, 1); kbd_key(KEY_TAB, 0); kbd_key(KEY_LEFTALT, 0); } /* Minus: cycle CW -> CCW -> UD -> none, then restart X to apply. * fbdev rotation is only read at server start and xrandr rejects it * ("using driver rotation"), so a restart is unavoidable. */ static void rotate_screen(void) { pid_t p = fork(); if (p == 0) { setsid(); execl("/usr/local/bin/rotate-next", "rotate-next", (char *)NULL); _exit(127); } fprintf(stderr, "jcmapper: rotating screen\n"); } /* Disabling mid-drag would otherwise leave a mouse button stuck down. */ static void release_buttons(void) { mouse_btn(BTN_LEFT, 0); mouse_btn(BTN_RIGHT, 0); mouse_btn(BTN_MIDDLE, 0); } static void handle_key(int side, unsigned short code, int val) { if (verbose) fprintf(stderr, "[%s] code=%u val=%d%s\n", side ? "R" : "L", code, val, enabled ? "" : " (off)"); /* Screenshot: enable/disable the whole scheme. Always honoured so the * mapping can always be turned back on. */ if (side == 0 && code == BTN_Z) { if (val == 1) { if (enabled) release_buttons(); enabled = !enabled; fprintf(stderr, "jcmapper: mapping %s\n", enabled ? "ENABLED" : "DISABLED"); } return; } /* Plus: always toggles the OSK, so there is a way to type even while * the rest of the mapping is disabled. */ if (side == 1 && code == BTN_START) { if (val == 1) osk_toggle(); return; } if (!enabled) return; if (side == 0) { /* LEFT Joy-Con */ switch (code) { case BTN_TL2: mouse_btn(BTN_RIGHT, val); return; /* ZL/LT right */ case BTN_TL: kbd_key(KEY_PAGEUP, val); return; /* L/LB PgUp */ case BTN_SELECT: if (val == 1) rotate_screen(); return;/* - rotate */ case BTN_THUMBL: if (val == 1) tap_key(KEY_CAPSLOCK); return; /* L3 caps */ case BTN_DPAD_UP: kbd_key(KEY_UP, val); return; case BTN_DPAD_DOWN: kbd_key(KEY_DOWN, val); return; case BTN_DPAD_LEFT: kbd_key(KEY_LEFT, val); return; case BTN_DPAD_RIGHT:kbd_key(KEY_RIGHT, val); return; } } else { /* RIGHT Joy-Con */ switch (code) { case BTN_TR2: mouse_btn(BTN_LEFT, val); return; /* ZR/RT left */ case BTN_TR: kbd_key(KEY_PAGEDOWN, val); return; /* R/RB PgDn */ case BTN_NORTH: if (val == 1) tap_key(KEY_ESC); return; /* X Escape */ case BTN_EAST: kbd_key(KEY_ENTER, val); return; /* A Enter */ case BTN_SOUTH: kbd_key(KEY_BACKSPACE, val); return; /* B Backspace */ case BTN_WEST: if (val == 1) task_switcher(); return; /* Y Alt+Tab */ case BTN_THUMBR: mouse_btn(BTN_MIDDLE, val); return; /* R3 middle */ case BTN_MODE: if (val == 1) tap_key(KEY_LEFTMETA); return; /* Home Super */ } } } /* stick -> relative motion, quadratic curve for fine control near centre. * * Returns a FRACTIONAL pixel count; the caller accumulates it and emits * whole pixels. Doing the division in double is essential: the old integer * version truncated twice (`a*a/span`, then `*MAXPX/span`), which collapsed * the output to 0 until ~38% deflection instead of the intended 15% * deadzone, then jumped straight into a steep ramp. That dead-then-lurch * response is what read as "twitchy". */ static double axis_delta(int v) { int a, span; double d; if (iabs(v) < DEADZONE) return 0.0; span = 32767 - DEADZONE; a = iabs(v) - DEADZONE; d = (double)a * a / span; /* quadratic: gentle near centre */ d = d * MAXPX / span; /* 0..MAXPX */ return v < 0 ? -d : d; } /* Right stick -> wheel notches. Returns a fractional notch rate; the caller * accumulates and emits whole REL_WHEEL units. MAXNOTCH caps how many notches * per 8ms tick, i.e. how fast a document can fly past. * * Same truncation bug as axis_delta: with MAXNOTCH=3 the integer path stayed * at 0 until ~64% deflection. Computed in double it responds from the * deadzone onward, and the accumulator smooths it into whole notches. */ #define MAXNOTCH 3 static double scroll_delta(int v) { int a, span; double d; if (iabs(v) < DEADZONE) return 0.0; span = 32767 - DEADZONE; a = iabs(v) - DEADZONE; d = (double)a * a / span; d = d * MAXNOTCH / span; return v < 0 ? -d : d; } /* ---------------- device discovery ---------------- */ static int open_by_name(const char *want) { FILE *f = fopen("/proc/bus/input/devices", "r"); char line[512], handlers[256] = ""; int match = 0, result = -1; if (!f) return -1; (void)0; while (fgets(line, sizeof line, f)) { if (!strncmp(line, "N: Name=", 8)) { match = (strstr(line, want) != NULL); handlers[0] = 0; } else if (!strncmp(line, "H: Handlers=", 12)) { snprintf(handlers, sizeof handlers, "%s", line + 12); } else if (line[0] == '\n') { if (match && handlers[0]) { char *p = strstr(handlers, "event"); if (p) { char path[64]; snprintf(path, sizeof path, "/dev/input/event%d", atoi(p + 5)); result = open(path, O_RDONLY | O_NONBLOCK); if (result >= 0) break; } } match = 0; handlers[0] = 0; } } fclose(f); return result; } int main(int argc, char **argv) { int fds[2] = { -1, -1 }; const char *names[2] = { JC_LEFT_NAME, JC_RIGHT_NAME }; int stick[2][2] = { {0,0}, {0,0} }; /* last known stick pos per side */ int i, retry = 0; for (i = 1; i < argc; i++) if (!strcmp(argv[i], "-v")) verbose = 1; /* Single instance. Two mappers both attach to the same Joy-Con fds and * both create a jcosk window, which then fight over XRaiseWindow and * flicker; every button also fires twice. flock fails fast if held. */ lock_fd = open("/var/run/jcmapper.lock", O_CREAT | O_RDWR, 0644); if (lock_fd < 0 || flock(lock_fd, LOCK_EX | LOCK_NB) < 0) { fprintf(stderr, "jcmapper: another instance is running, exiting\n"); if (lock_fd >= 0) close(lock_fd); return 0; } mouse_fd = uinput_create(MOUSE_NAME, 1, 0); kbd_fd = uinput_create(KBD_NAME, 0, 1); if (mouse_fd < 0 || kbd_fd < 0) return 1; usleep(300000); /* let the nodes register */ fprintf(stderr, "jcmapper: created '%s' and '%s'%s\n", MOUSE_NAME, KBD_NAME, verbose ? " (verbose)" : ""); for (;;) { struct pollfd p[2]; int n = 0, idx[2] = { -1, -1 }; for (i = 0; i < 2; i++) { if (fds[i] < 0 && retry <= 0) { fds[i] = open_by_name(names[i]); if (fds[i] >= 0) fprintf(stderr, "jcmapper: attached to %s Joy-Con\n", i ? "right" : "left"); } if (fds[i] >= 0) { p[n].fd = fds[i]; p[n].events = POLLIN; idx[n] = i; n++; } } if (retry > 0) retry--; if (poll(p, n, TICK_MS) > 0) { for (i = 0; i < n; i++) { struct input_event e; ssize_t r; if (!(p[i].revents & POLLIN)) continue; while ((r = read(p[i].fd, &e, sizeof e)) == (ssize_t)sizeof e) { if (e.type == EV_KEY) { handle_key(idx[i], e.code, e.value); } else if (e.type == EV_ABS) { /* remember position; motion is emitted below so a held stick keeps moving (evdev only reports change) */ if (e.code == ABS_X || e.code == ABS_RX) stick[idx[i]][0] = e.value; else if (e.code == ABS_Y || e.code == ABS_RY) stick[idx[i]][1] = e.value; } } if (r < 0 && errno == ENODEV) { /* Joy-Con reseated */ fprintf(stderr, "jcmapper: %s Joy-Con gone, will reattach\n", idx[i] ? "right" : "left"); close(fds[i]); fds[i] = -1; retry = 125; } } } /* reap the OSK if it exited on its own */ if (osk_pid > 0 && waitpid(osk_pid, NULL, WNOHANG) == osk_pid) osk_pid = 0; if (enabled) { /* left stick -> mouse motion. * * axis_delta() now returns a fractional pixel count, so accumulate * it and only emit on whole pixels. Casting straight to int here * would reintroduce the truncation that made the curve dead below * 38% deflection. Residue is deliberately kept: it is what lets a * slow, near-centre nudge still move the cursor one pixel. */ axis_acc_x += axis_delta(stick[0][0]); axis_acc_y += axis_delta(stick[0][1]); { int dx = (int)axis_acc_x; int dy = (int)axis_acc_y; if (dx) { emit(mouse_fd, EV_REL, REL_X, dx); axis_acc_x -= dx; } if (dy) { emit(mouse_fd, EV_REL, REL_Y, dy); axis_acc_y -= dy; } } /* right stick -> scrolling, accumulated into whole notches. * stick down scrolls down, stick right scrolls right. */ wheel_acc_y -= scroll_delta(stick[1][1]); wheel_acc_x += scroll_delta(stick[1][0]); { int ny = (int)wheel_acc_y; int nx = (int)wheel_acc_x; if (ny) { emit(mouse_fd, EV_REL, REL_WHEEL, ny); wheel_acc_y -= ny; } if (nx) { emit(mouse_fd, EV_REL, REL_HWHEEL, nx); wheel_acc_x -= nx; } } } send_sync(mouse_fd); } return 0; }