From 9060ab44187f75bfbe558c78e34dd5016921b434 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 10 Jul 2026 00:29:23 -0500 Subject: [PATCH] add linuxJoystick input module (#10970) * add linuxJoystick input module * close epollfd to avoid leaks --- bin/config-dist.yaml | 12 +- src/Power.cpp | 3 + src/input/InputBroker.cpp | 4 + src/input/LinuxInput.cpp | 10 +- src/input/LinuxInput.h | 2 +- src/input/LinuxJoystick.cpp | 159 +++++++++++++++++++++++ src/input/LinuxJoystick.h | 51 ++++++++ src/platform/portduino/PortduinoGlue.cpp | 19 +++ src/platform/portduino/PortduinoGlue.h | 13 ++ 9 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 src/input/LinuxJoystick.cpp create mode 100644 src/input/LinuxJoystick.h diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index dcea78ee5..4b4fe0b82 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -173,6 +173,16 @@ Input: # KeyboardDevice: /dev/input/by-id/usb-_Raspberry_Pi_Internal_Keyboard-event-kbd +### Configure device for USB gamepad/joystick input (evdev). The D-pad drives +### Up/Down/Left/Right; buttons are mapped below. Find button codes with `evtest`. +# JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick +### Map evdev button codes (hex or decimal) to actions. Omit to use built-in +### defaults (select=0x121, cancel=0x122). Actions: select, cancel, back, up, +### down, left, right, user. +# JoystickButtons: +# select: 0x122 +# cancel: 0x121 + ### Standard User Button Config # UserButton: 6 @@ -199,13 +209,11 @@ Webserver: # SSLKey: /etc/meshtasticd/ssl/private_key.pem # Path to SSL Key, generated if not present # SSLCert: /etc/meshtasticd/ssl/certificate.pem # Path to SSL Certificate, generated if not present - HostMetrics: # ReportInterval: 30 # Interval in minutes between HostMetrics report packets, or 0 for disabled # Channel: 0 # channel to send Host Metrics over. Defaults to the primary channel. # UserStringCommand: cat /sys/firmware/devicetree/base/serial-number # Command to execute, to send the results as the userString - Config: # DisplayMode: TWOCOLOR # uncomment to force BaseUI # DisplayMode: COLOR # uncomment to force MUI diff --git a/src/Power.cpp b/src/Power.cpp index c3aa60111..1c1f34cd4 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -37,6 +37,7 @@ #if defined(ARCH_PORTDUINO) #include "api/WiFiServerAPI.h" #include "input/LinuxInputImpl.h" +#include "input/LinuxJoystick.h" #endif // Working USB detection for powered/charging states on the RAK platform @@ -860,6 +861,8 @@ void Power::reboot() #ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); + if (aLinuxJoystick) + aLinuxJoystick->deInit(); #endif SPI.end(); Wire.end(); diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 72af749bc..d6c8fe832 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -10,6 +10,7 @@ #if ARCH_PORTDUINO #include "input/LinuxInputImpl.h" +#include "input/LinuxJoystick.h" #include "input/SeesawRotary.h" #include "platform/portduino/PortduinoGlue.h" #endif @@ -430,6 +431,9 @@ void InputBroker::Init() // Linux evdev keyboard input only - macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); + // Linux evdev gamepad/joystick input (D-pad + confirm/cancel buttons). + aLinuxJoystick = new LinuxJoystick("LinuxJoystick"); + aLinuxJoystick->init(); #endif } #endif diff --git a/src/input/LinuxInput.cpp b/src/input/LinuxInput.cpp index fee7c8ded..566f2cba1 100644 --- a/src/input/LinuxInput.cpp +++ b/src/input/LinuxInput.cpp @@ -25,8 +25,14 @@ LinuxInput::LinuxInput(const char *name) : concurrency::OSThread(name) void LinuxInput::deInit() { + // reboot() on native does execv() in-place, which only closes O_CLOEXEC fds. + // Close both descriptors here so we don't leak one per restart. if (fd >= 0) close(fd); + if (epollfd >= 0) + close(epollfd); + fd = -1; + epollfd = -1; } int32_t LinuxInput::runOnce() @@ -35,14 +41,14 @@ int32_t LinuxInput::runOnce() if (firstTime) { if (portduino_config.keyboardDevice == "") return disable(); - fd = open(portduino_config.keyboardDevice.c_str(), O_RDWR); + fd = open(portduino_config.keyboardDevice.c_str(), O_RDWR | O_CLOEXEC); if (fd < 0) return disable(); ret = ioctl(fd, EVIOCGRAB, (void *)1); if (ret != 0) return disable(); - epollfd = epoll_create1(0); + epollfd = epoll_create1(EPOLL_CLOEXEC); assert(epollfd >= 0); ev.events = EPOLLIN; diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 673d29b3c..4a4b16723 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -44,7 +44,7 @@ class LinuxInput : public Observable, public concurrency::OS int fd = -1; int ret; uint8_t report[8]; - int epollfd; + int epollfd = -1; struct epoll_event ev; uint8_t modifiers = 0; std::map keymap{ diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp new file mode 100644 index 000000000..aaff43959 --- /dev/null +++ b/src/input/LinuxJoystick.cpp @@ -0,0 +1,159 @@ +#include "configuration.h" +#if ARCH_PORTDUINO && defined(__linux__) +#include "InputBroker.h" +#include "LinuxJoystick.h" +#include "platform/portduino/PortduinoGlue.h" +#include +#include +#include +#include +#include +#include +#include + +LinuxJoystick *aLinuxJoystick; + +LinuxJoystick::LinuxJoystick(const char *name) : concurrency::OSThread(name) +{ + this->_originName = name; +} + +// Translate a config.yaml action name into a broker event. Returns INPUT_BROKER_NONE +// for unknown names so a typo simply leaves that button unmapped. +static input_broker_event joystickActionToEvent(const std::string &action) +{ + if (action == "select") + return INPUT_BROKER_SELECT; + if (action == "cancel") + return INPUT_BROKER_CANCEL; + if (action == "back") + return INPUT_BROKER_BACK; + if (action == "up") + return INPUT_BROKER_UP; + if (action == "down") + return INPUT_BROKER_DOWN; + if (action == "left") + return INPUT_BROKER_LEFT; + if (action == "right") + return INPUT_BROKER_RIGHT; + if (action == "user" || action == "userpress") + return INPUT_BROKER_USER_PRESS; + return INPUT_BROKER_NONE; +} + +void LinuxJoystick::init() +{ + if (portduino_config.joystickButtons.empty()) { + // No config override: fall back to the built-in defaults. + buttonMap[JOY_BTN_A] = INPUT_BROKER_SELECT; + buttonMap[JOY_BTN_B] = INPUT_BROKER_CANCEL; + } else { + for (const auto &button : portduino_config.joystickButtons) { + input_broker_event event = joystickActionToEvent(button.second); + if (event != INPUT_BROKER_NONE) + buttonMap[button.first] = event; + } + } + inputBroker->registerSource(this); +} + +void LinuxJoystick::deInit() +{ + // reboot() on native does execv() in-place, which only closes O_CLOEXEC fds. + // Close both descriptors here so we don't leak one per restart. + if (fd >= 0) + close(fd); + if (epollfd >= 0) + close(epollfd); + fd = -1; + epollfd = -1; +} + +// Bucket a raw axis value into a direction zone: -1 (min edge), 0 (center), +1 (max edge). +static int axisZone(int value) +{ + if (value <= JOY_AXIS_LOW) + return -1; + if (value >= JOY_AXIS_HIGH) + return 1; + return 0; +} + +int32_t LinuxJoystick::runOnce() +{ + if (firstTime) { + if (portduino_config.joystickDevice == "") + return disable(); + fd = open(portduino_config.joystickDevice.c_str(), O_RDWR | O_CLOEXEC); + if (fd < 0) + return disable(); + if (ioctl(fd, EVIOCGRAB, (void *)1) != 0) + return disable(); + + epollfd = epoll_create1(EPOLL_CLOEXEC); + assert(epollfd >= 0); + + ev.events = EPOLLIN; + ev.data.fd = fd; + if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev)) { + perror("joystick: unable to epoll add"); + return disable(); + } + // This is the first time the OSThread library has called this function, so do port setup + firstTime = false; + } + + int nfds = epoll_wait(epollfd, events, JOY_MAX_EVENTS, 1); + if (nfds < 0) { + perror("joystick: epoll_wait failed"); + return disable(); + } else if (nfds == 0) { + return 50; + } + + for (int i = 0; i < nfds; i++) { + struct input_event evs[64]; + int rd = read(events[i].data.fd, evs, sizeof(evs)); + if (rd < (signed int)sizeof(struct input_event)) + continue; + for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) { + InputEvent e = {}; + e.inputEvent = INPUT_BROKER_NONE; + e.source = this->_originName; + e.kbchar = 0; + unsigned int type = evs[j].type; + unsigned int code = evs[j].code; + int value = evs[j].value; + + if (type == EV_ABS) { + // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. + // Emit exactly once when an axis crosses from center to an edge. + if (code == ABS_X) { + int zone = axisZone(value); + if (zone != axisZone(lastX) && zone != 0) + e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT; + lastX = value; + } else if (code == ABS_Y) { + int zone = axisZone(value); + if (zone != axisZone(lastY) && zone != 0) + e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN; + lastY = value; + } + } else if (type == EV_KEY && value == 1) { + // Look up the configured action for this button; unmapped buttons are ignored. + auto mapped = buttonMap.find(code); + if (mapped != buttonMap.end()) + e.inputEvent = mapped->second; + } + + if (e.inputEvent != INPUT_BROKER_NONE) { + LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent); + this->notifyObservers(&e); + } + } + } + + return 50; // Poll every 50msec +} + +#endif diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h new file mode 100644 index 000000000..6e6d3c398 --- /dev/null +++ b/src/input/LinuxJoystick.h @@ -0,0 +1,51 @@ +#pragma once +// Linux evdev gamepad/joystick input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll. Unlike LinuxInput +// (keyboard, EV_KEY only) this decodes the D-pad from EV_ABS axes as well. +#if ARCH_PORTDUINO && defined(__linux__) +#include "InputBroker.h" +#include "concurrency/OSThread.h" +#include +#include +#include +#include + +#define JOY_MAX_EVENTS 10 + +// Default button map when config.yaml has no [Input] JoystickButtons override. +// Codes confirmed by evdev capture on a 0079:0011 "USB Gamepad". +#define JOY_BTN_A BTN_THUMB // 0x121 -> INPUT_BROKER_SELECT +#define JOY_BTN_B BTN_THUMB2 // 0x122 -> INPUT_BROKER_CANCEL +#define JOY_AXIS_CENTER 127 // D-pad resting value +#define JOY_AXIS_LOW 64 // below this -> "min" edge (0) +#define JOY_AXIS_HIGH 192 // above this -> "max" edge (255) + +class LinuxJoystick : public Observable, public concurrency::OSThread +{ + public: + explicit LinuxJoystick(const char *name); + void init(); // Registers this source with the InputBroker + void deInit(); // Strictly for cleanly "rebooting" the binary on native + + protected: + virtual int32_t runOnce() override; + + private: + const char *_originName; + bool firstTime = true; + + // evdev button code -> broker event, built from config (or defaults) in init(). + std::map buttonMap; + + struct epoll_event events[JOY_MAX_EVENTS]; + struct epoll_event ev; + int fd = -1; + int epollfd = -1; + + // Latch the last emitted axis position so a held direction fires exactly + // once (on the transition away from center), not a continuous stream. + int lastX = JOY_AXIS_CENTER; + int lastY = JOY_AXIS_CENTER; +}; +extern LinuxJoystick *aLinuxJoystick; +#endif diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 74790cd02..7bb8096ab 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1038,6 +1038,25 @@ bool loadConfig(const char *configPath) if (yamlConfig["Input"]) { portduino_config.keyboardDevice = (yamlConfig["Input"]["KeyboardDevice"]).as(""); portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as(""); + portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as(""); + if (yamlConfig["Input"]["JoystickButtons"]) { + // action name -> evdev button code (hex like 0x122 or decimal); stored inverted + // as code -> lowercase action name for the driver to look up per keypress. + for (const auto &button : yamlConfig["Input"]["JoystickButtons"]) { + std::string action = button.first.as(""); + for (auto &c : action) + c = tolower(c); + int code = 0; + try { + // base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry. + code = std::stoi(button.second.as(""), nullptr, 0); + } catch (const std::exception &) { + code = 0; + } + if (code != 0 && action != "") + portduino_config.joystickButtons[code] = action; + } + } readGPIOFromYaml(yamlConfig["Input"]["User"], portduino_config.userButtonPin); readGPIOFromYaml(yamlConfig["Input"]["TrackballUp"], portduino_config.tbUpPin); diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 73bfbdcf7..050aaaf87 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -160,6 +160,11 @@ extern struct portduino_config_struct { // Input std::string keyboardDevice = ""; std::string pointerDevice = ""; + std::string joystickDevice = ""; + // Joystick/gamepad button map: evdev button code -> lowercase action name + // ("select", "cancel", "back", "up", "down", "left", "right", "user"). + // Empty means the LinuxJoystick driver uses its built-in defaults. + std::map joystickButtons; int tbDirection; pinMapping userButtonPin = {"Input", "User"}; pinMapping tbUpPin = {"Input", "TrackballUp"}; @@ -458,6 +463,14 @@ extern struct portduino_config_struct { out << YAML::Key << "KeyboardDevice" << YAML::Value << keyboardDevice; if (pointerDevice != "") out << YAML::Key << "PointerDevice" << YAML::Value << pointerDevice; + if (joystickDevice != "") + out << YAML::Key << "JoystickDevice" << YAML::Value << joystickDevice; + if (!joystickButtons.empty()) { + out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap; + for (const auto &button : joystickButtons) + out << YAML::Key << button.second << YAML::Value << button.first; + out << YAML::EndMap; + } for (const auto *input_pin : all_pins) { if (input_pin->config_section == "Input" && input_pin->enabled) {