/*
* 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>
#include <errno.h>
#include <linux/input.h>
#include <linux/uinput.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <sys/file.h>
#include <signal.h>
#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;
}