diff --git a/VortexEngine/VortexCLI/Makefile b/VortexEngine/VortexCLI/Makefile index 033466cc91..c7612b111f 100644 --- a/VortexEngine/VortexCLI/Makefile +++ b/VortexEngine/VortexCLI/Makefile @@ -76,9 +76,9 @@ LIBS=\ # source files # local source files first, other sources after ifeq ($(OS),Windows_NT) - SRC = VortexCLI.cpp + SRC = VortexCLI.cpp VirtualSerial.cpp else - SRC = VortexCLI.cpp + SRC = VortexCLI.cpp VirtualSerial.cpp endif # object files are source files with .c replaced with .o diff --git a/VortexEngine/VortexCLI/VirtualSerial.cpp b/VortexEngine/VortexCLI/VirtualSerial.cpp new file mode 100644 index 0000000000..7039b81bd7 --- /dev/null +++ b/VortexEngine/VortexCLI/VirtualSerial.cpp @@ -0,0 +1,278 @@ +#include "VirtualSerial.h" + +#ifndef WASM +#include +#include +#include +#include +#include +#include +#include +#endif +#include +#include +#include +#include + +// hard cap on the rx/tx buffers so a stuck client can't grow them forever +#define VIRTUAL_SERIAL_BUFFER_MAX (1 << 20) +// how many recently transmitted bytes to keep for echo detection +#define VIRTUAL_SERIAL_ECHO_WINDOW 8192 + +// whether a received chunk is an echo of data we just sent: it must match the +// tail of the recent tx window exactly, real editor commands are single +// letters and can never collide with our own (much longer) messages +static bool isEcho(const std::vector &recentTx, const uint8_t *data, size_t amt) +{ + if (!amt || amt > recentTx.size()) { + return false; + } + return memcmp(&recentTx[recentTx.size() - amt], data, amt) == 0; +} + +// echo one chunk of serial traffic to the console, printable bytes are shown +// as-is and everything else is escaped so binary payloads stay readable +static void logTraffic(const char *dir, const uint8_t *data, size_t amt) +{ + if (!amt) { + return; + } + printf("[%s] ", dir); + for (size_t i = 0; i < amt; ++i) { + uint8_t c = data[i]; + switch (c) { + case '\n': printf("\\n"); break; + case '\r': printf("\\r"); break; + case '\t': printf("\\t"); break; + default: + if (c >= 0x20 && c < 0x7F) { + putchar(c); + } else { + printf("\\x%02X", c); + } + break; + } + } + printf("\n"); + fflush(stdout); +} + +VirtualSerial::VirtualSerial() : + m_backend(BACKEND_PTY), + m_fd(-1), + m_hasClient(false), + m_logTraffic(false), + m_baud(115200) +{ +} + +VirtualSerial::~VirtualSerial() +{ + cleanup(); +} + +bool VirtualSerial::init(Backend backend) +{ + m_backend = backend; + switch (m_backend) { + case BACKEND_PTY: + return initPty(); + } + return false; +} + +void VirtualSerial::cleanup() +{ + if (m_fd >= 0) { + close(m_fd); + } + m_fd = -1; + m_hasClient = false; + m_rx.clear(); + m_tx.clear(); + m_recentTx.clear(); +} + +// --------------------------------------------------------------------------- +// backends +// --------------------------------------------------------------------------- + +bool VirtualSerial::initPty() +{ +#if defined(_WIN32) || defined(WASM) + return false; +#else + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + perror("posix_openpt"); + return false; + } + if (grantpt(master) != 0 || unlockpt(master) != 0) { + perror("grantpt/unlockpt"); + close(master); + return false; + } + char name[128] = {0}; + if (ptsname_r(master, name, sizeof(name)) != 0) { + perror("ptsname_r"); + close(master); + return false; + } + fcntl(master, F_SETFL, O_NONBLOCK); + // force the pty to raw mode with no echo. a pty's termios is sticky across + // master reincarnations, so if a previous client left echo/canonical mode + // enabled our own outgoing data would bounce straight back into the master + // and get mistaken for client input. raw/no-echo also matches the real + // firmware device, which is a bare cdc serial port. + struct termios tio; + memset(&tio, 0, sizeof(tio)); + if (tcgetattr(master, &tio) == 0) { + cfmakeraw(&tio); + cfsetspeed(&tio, (speed_t)m_baud); + tcsetattr(master, TCSANOW, &tio); + } + m_fd = master; + m_path = name; + // the port starts disconnected: nothing is ever sent until a client has + // the slave side open, pumpPty() detects that through the master's HUP + m_hasClient = false; + return true; +#endif +} + +// --------------------------------------------------------------------------- +// polling +// --------------------------------------------------------------------------- + +void VirtualSerial::poll() +{ + pumpPty(); + flushTx(); + drainRx(); +} + +void VirtualSerial::pumpPty() +{ +#if defined(_WIN32) || defined(WASM) + return; +#else + if (m_fd < 0) { + return; + } + struct pollfd pfd = { m_fd, POLLIN | POLLHUP, 0 }; + if (::poll(&pfd, 1, 0) < 0) { + return; + } + // a pty master reports HUP/ERR whenever nothing has the slave side open, + // including before the very first client connects, so this is an accurate + // live view of whether anyone is actually attached to the port + m_hasClient = !(pfd.revents & (POLLHUP | POLLERR)); +#endif +} + +void VirtualSerial::flushTx() +{ +#ifndef WASM + while (!m_tx.empty() && m_hasClient) { + ssize_t written = 0; +#ifdef _WIN32 + written = send(m_fd, &m_tx[0], m_tx.size(), 0); +#else + written = ::write(m_fd, &m_tx[0], m_tx.size()); +#endif + if (written < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + // try again on the next poll + return; + } + if (errno == EIO || errno == ENXIO) { + // no slave side open on a pty, wait for the client to come back + return; + } + return; + } + m_tx.erase(m_tx.begin(), m_tx.begin() + (size_t)written); + } +#endif +} + +void VirtualSerial::drainRx() +{ +#ifndef WASM + uint8_t buf[1024]; + while (m_hasClient) { +#ifdef _WIN32 + ssize_t got = recv(m_fd, buf, sizeof(buf), 0); + if (got <= 0) { + if (got == 0) { + // client closed the connection + close(m_fd); + m_fd = -1; + m_hasClient = false; + } + return; + } +#else + ssize_t got = ::read(m_fd, buf, sizeof(buf)); + if (got < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return; + } + return; + } + if (got == 0) { + return; + } +#endif + if (m_rx.size() + (size_t)got > VIRTUAL_SERIAL_BUFFER_MAX) { + // drop the oldest data to keep the buffer bounded + size_t over = (m_rx.size() + (size_t)got) - VIRTUAL_SERIAL_BUFFER_MAX; + m_rx.erase(m_rx.begin(), m_rx.begin() + over); + } + // drop our own data bouncing back off the line before it can poison the + // engine's receive buffer or be mistaken for an editor command + if (isEcho(m_recentTx, buf, (size_t)got)) { + if (m_logTraffic) { + logTraffic("ECHO", buf, (size_t)got); + } + continue; + } + m_rx.insert(m_rx.end(), buf, buf + got); + if (m_logTraffic) { + logTraffic("RX", buf, (size_t)got); + } + } +#endif +} + +// --------------------------------------------------------------------------- +// engine <-> port io +// --------------------------------------------------------------------------- + +size_t VirtualSerial::read(char *buf, size_t amt) +{ + size_t count = (amt < m_rx.size()) ? amt : m_rx.size(); + if (!count) { + return 0; + } + memcpy(buf, &m_rx[0], count); + m_rx.erase(m_rx.begin(), m_rx.begin() + count); + return count; +} + +uint32_t VirtualSerial::write(const uint8_t *buf, size_t amt) +{ + if (m_logTraffic) { + logTraffic("TX", buf, amt); + } + // remember what we sent so anything identical that comes back is detected + m_recentTx.insert(m_recentTx.end(), buf, buf + amt); + if (m_recentTx.size() > VIRTUAL_SERIAL_ECHO_WINDOW) { + m_recentTx.erase(m_recentTx.begin(), m_recentTx.end() - VIRTUAL_SERIAL_ECHO_WINDOW); + } + if (m_tx.size() + amt > VIRTUAL_SERIAL_BUFFER_MAX) { + amt = VIRTUAL_SERIAL_BUFFER_MAX - m_tx.size(); + } + m_tx.insert(m_tx.end(), buf, buf + amt); + return (uint32_t)amt; +} diff --git a/VortexEngine/VortexCLI/VirtualSerial.h b/VortexEngine/VortexCLI/VirtualSerial.h new file mode 100644 index 0000000000..869ac9b1b6 --- /dev/null +++ b/VortexEngine/VortexCLI/VirtualSerial.h @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include +#include + +// VirtualSerial +// +// A virtual serial port that lets the vortex engine emulate a USB-connected +// device so that external software (such as the lightshow.lol editor over +// Web Serial) can talk to it exactly like a real device. +// +// BACKEND_PTY create a pty (/dev/pts/N) that any serial tool can open. +// This is the default and works everywhere without root. +// The port is only 'connected' while a client actually has +// the slave side open (the master reports POLLHUP whenever +// nothing has it open). Web Serial cannot see the pty +// itself, so the pty is bridged to a real serial port with +// socat (a USB-TTL dongle or a synthesized USB gadget). +// +// Nothing is ever sent until a client is attached: the engine detects the +// connection through the normal isConnected()/checkSerial() flow and sends +// its greeting exactly once per connect, just like real firmware. +class VirtualSerial +{ +public: + enum Backend { + BACKEND_PTY = 0, + }; + + VirtualSerial(); + ~VirtualSerial(); + + // create the port, returns false if it could not be created + bool init(Backend backend); + + // tear down the port and any connections + void cleanup(); + + // pump data between the port and the internal rx/tx buffers, call this + // every engine tick while running + void poll(); + + // whether a client is currently connected to the port + bool hasClient() const { return m_hasClient; } + + // number of bytes available to be read by the engine + int32_t avail() const { return (int32_t)m_rx.size(); } + + // read data from the port into the engine + size_t read(char *buf, size_t amt); + + // write data from the engine to the port + uint32_t write(const uint8_t *buf, size_t amt); + + // the display name of the port (ex: /dev/pts/3) + const char *path() const { return m_path.c_str(); } + + // echo all tx/rx serial traffic to the console for debugging + void setLogTraffic(bool log) { m_logTraffic = log; } + +private: + bool initPty(); + + void pumpPty(); + + // flush the tx buffer out to the port + void flushTx(); + // read any available bytes from the port into the rx buffer + void drainRx(); + + Backend m_backend; + int m_fd; // master pty fd + bool m_hasClient; + bool m_logTraffic; + uint32_t m_baud; + std::string m_path; + + std::vector m_rx; + std::vector m_tx; + // rolling window of recently transmitted bytes so anything that comes back + // byte-for-byte identical (pty/tty line discipline echo, gadget quirks) + // can be recognized and dropped instead of being fed to the engine as + // fake editor input + std::vector m_recentTx; +}; diff --git a/VortexEngine/VortexCLI/VortexCLI.cpp b/VortexEngine/VortexCLI/VortexCLI.cpp index 89ca527e04..527003271c 100644 --- a/VortexEngine/VortexCLI/VortexCLI.cpp +++ b/VortexEngine/VortexCLI/VortexCLI.cpp @@ -18,8 +18,13 @@ #include #include #include +#include #include #include +#include +#include +#include +#include #include "VortexCLI.h" @@ -44,6 +49,22 @@ VortexCLI *g_pVortexCLI = nullptr; +// set when SIGINT/SIGTERM is caught so the main loop can wind down cleanly and +// tear down the virtual USB gadget before the process exits +static volatile sig_atomic_t s_signalExit = 0; + +static void handle_signal(int sig) +{ + if (s_signalExit) { + // the graceful wind-down is already under way; a second signal means the + // user is impatient, so restore the default action and let the OS kill us + signal(sig, SIG_DFL); + raise(sig); + return; + } + s_signalExit = 1; +} + using namespace std; // the usage for the input strings @@ -166,6 +187,16 @@ VortexCLI::VortexCLI() : m_patternIDStr(), m_colorsetStr(), m_argumentsStr(), + m_editorMode(false), + m_virtualSerial(nullptr), + m_bridgeStarted(false), + m_socatPid(-1), + m_bridgeScanTick(0), + m_gadgetAttempted(false), + m_gadgetReady(false), + m_bridgeDevice(), + m_hostConnected(false), + m_hostCheckTick(0), m_pipe_fd{-1, -1}, m_saved_stdin(), m_inputBuffer() @@ -197,6 +228,7 @@ static struct option long_options[] = { {"pattern", required_argument, nullptr, 'P'}, {"colorset", required_argument, nullptr, 'C'}, {"arguments", required_argument, nullptr, 'A'}, + {"editor", no_argument, nullptr, 'e'}, {"help", no_argument, nullptr, 'h'}, {nullptr, 0, nullptr, 0} }; @@ -256,6 +288,15 @@ static void print_usage(const char* program_name) fprintf(stderr, " -C, --colorset c1,c2... Preset the colorset on the first mode (csv list of hex codes or color names)\n"); fprintf(stderr, " -A, --arguments a1,a2... Preset the arguments on the first mode (csv list of arguments)\n"); fprintf(stderr, "\n"); + fprintf(stderr, "Virtual Device (optional):\n"); + fprintf(stderr, " -e, --editor Expose the engine as a virtual serial device so the lightshow.lol\n"); + fprintf(stderr, " editor can connect to it like a real USB device. Creates a pty and\n"); + fprintf(stderr, " auto-bridges it to a USB serial port with socat so the browser's\n"); + fprintf(stderr, " Web Serial can enumerate it as a real COM port. With no hardware\n"); + fprintf(stderr, " present it synthesizes one in the kernel (needs sudo once per boot).\n"); + fprintf(stderr, " When no output option is given this implies --silent.\n"); + fprintf(stderr, " All serial traffic is echoed to the console as [TX]/[RX].\n"); + fprintf(stderr, "\n"); fprintf(stderr, "Other Options:\n"); fprintf(stderr, " -h, --help Display this help message\n"); fprintf(stderr, "\n"); @@ -289,9 +330,24 @@ void set_terminal_nonblocking() } #else static struct termios orig_term_attr = {0}; +static int orig_stdin_flags = -1; +static bool s_terminalNonblock = false; static void restore_terminal() { + // only restore if we actually put the terminal into non-blocking mode; + // otherwise orig_term_attr is all zeros and would clobber a sane terminal + if (!s_terminalNonblock) { + return; + } tcsetattr(STDIN_FILENO, TCSANOW, &orig_term_attr); + // clear O_NONBLOCK as well, restoring the termios alone is not enough: + // a non-blocking stdin makes any sudo password prompt fail instantly + // because its read() returns EAGAIN instead of waiting for input + if (orig_stdin_flags >= 0) { + fcntl(STDIN_FILENO, F_SETFL, orig_stdin_flags); + orig_stdin_flags = -1; + } + s_terminalNonblock = false; } void set_terminal_nonblocking() @@ -316,8 +372,11 @@ void set_terminal_nonblocking() // Set the terminal to non-blocking mode int flags = fcntl(STDIN_FILENO, F_GETFL, 0); + orig_stdin_flags = flags; fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); + s_terminalNonblock = true; + // Register the restore_terminal function to be called at exit atexit(restore_terminal); } @@ -351,6 +410,13 @@ bool VortexCLI::init(int argc, char *argv[]) } g_pVortexCLI = this; + // catch ctrl-c / SIGTERM so cleanup() runs (and the virtual USB gadget is + // torn down) instead of the process just being killed +#ifndef WASM + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); +#endif + if (argc == 1) { print_usage(argv[0]); exit(1); @@ -358,7 +424,7 @@ bool VortexCLI::init(int argc, char *argv[]) int opt = -1; int option_index = 0; - while ((opt = getopt_long(argc, argv, "xcstliranqS::W:M:L:I::O::HP:C:A:h", long_options, &option_index)) != -1) { + while ((opt = getopt_long(argc, argv, "xcstliranqS::W:M:L:I::O::HP:C:A:eh", long_options, &option_index)) != -1) { switch (opt) { case 'x': // if the user wants pretty colors or hex codes @@ -459,7 +525,12 @@ bool VortexCLI::init(int argc, char *argv[]) // preset the arguments on the first mode m_argumentsStr = optarg; break; - case 'h': + case 'e': + // expose the engine as a virtual serial device for the editor website, + // it emulates a plugged-in device (pty + socat bridge to a USB serial + // port) so no backend argument is needed + m_editorMode = true; + break; case 'h': // print usage and exit print_usage(argv[0]); exit(EXIT_SUCCESS); @@ -471,6 +542,11 @@ bool VortexCLI::init(int argc, char *argv[]) switch (m_outputType) { case OUTPUT_TYPE_NONE: + if (m_editorMode) { + // the virtual device needs no terminal output, default to silent + m_outputType = OUTPUT_TYPE_SILENT; + break; + } print_usage(argv[0]); exit(EXIT_SUCCESS); break; @@ -484,6 +560,14 @@ bool VortexCLI::init(int argc, char *argv[]) break; } + // the virtual device protocol depends on the engine running in real time, + // lockstep/in-place/no-timestep would break the serial handshake timing + if (m_editorMode) { + m_lockstep = false; + m_inPlace = false; + m_noTimestep = false; + } + // do the vortex init/setup Vortex::initEx(); @@ -582,6 +666,47 @@ bool VortexCLI::init(int argc, char *argv[]) // TODO: add arg for the led position Vortex::setPatternArgs(LED_ALL, args); } + if (m_editorMode) { +#ifndef WASM + // the tool fully emulates a plugged-in device: create a pty and bridge it + // to a real USB serial device with socat so the browser's Web Serial can + // enumerate it. the pty itself is not visible to Web Serial, so the + // bridged device is what the browser opens. + bool socatFound = false; + const char *pathEnv = getenv("PATH"); + if (pathEnv) { + std::string paths(pathEnv); + size_t start = 0; + while (start <= paths.size()) { + size_t end = paths.find(':', start); + std::string dir = (end == std::string::npos) ? paths.substr(start) : paths.substr(start, end - start); + if (dir.size() > 0 && access((dir + "/socat").c_str(), X_OK) == 0) { + socatFound = true; + break; + } + if (end == std::string::npos) break; + start = end + 1; + } + } + if (!socatFound) { + printf("The auto bridge requires 'socat' which is not installed.\n"); + printf(" sudo pacman -S socat (arch)\n"); + printf(" sudo apt install socat (debian/ubuntu)\n"); + exit(2); + } + m_virtualSerial = new VirtualSerial(); + // echo all serial traffic to the console so the editor protocol can be + // observed live while the editor connection menu runs + m_virtualSerial->setLogTraffic(true); + if (!m_virtualSerial->init(VirtualSerial::BACKEND_PTY)) { + printf("Failed to create virtual serial port\n"); + exit(2); + } + printf("Virtual serial device: %s\n", m_virtualSerial->path()); + startSocatBridge(); + } +#endif + if (m_inPlace && !system("clear")) { printf("Failed to clear\n"); } @@ -606,6 +731,22 @@ void VortexCLI::run() if (!stillRunning()) { return; } + // a SIGINT/SIGTERM handler asked us to stop, wind down on this tick + if (s_signalExit) { + m_quickExit = true; + } + // pump any data between the virtual serial port and the engine + if (m_virtualSerial) { + m_virtualSerial->poll(); + } + if (m_editorMode) { + // keep the socat bridge alive: retry if no device was present at startup + // or restart it if the device was unplugged + pollBridge(); + // watch for Web Serial actually opening/closing the host-side port so + // the engine only ever sees a connected editor when one truly is + updateHostConnection(); + } if (!Vortex::tick() || m_quickExit) { cleanup(); } @@ -671,6 +812,20 @@ void VortexCLI::cleanup() if (m_storage) { Vortex::doSave(); } + if (m_socatPid > 0) { +#ifndef WASM + kill(m_socatPid, SIGTERM); +#endif + m_socatPid = -1; + } + if (m_virtualSerial) { + delete m_virtualSerial; + m_virtualSerial = nullptr; + } + m_hostConnected = false; + // tear down the virtual USB gadget (ttyACM*) that was synthesized for the + // editor bridge, so it doesn't linger after the tool exits + teardownVirtualGadget(); Vortex::cleanup(); #ifdef WASM emscripten_force_exit(0); @@ -780,9 +935,627 @@ void VortexCLI::VortexCLICallbacks::ledsShow() g_pVortexCLI->show(); } +bool VortexCLI::VortexCLICallbacks::serialCheck() +{ + // must respect the full end-to-end link state, not just whether socat has + // the pty open, otherwise the engine latches 'connected' before Web Serial + return g_pVortexCLI->isEditorConnected(); +} + +void VortexCLI::VortexCLICallbacks::serialBegin(uint32_t baud) +{ +} + +int32_t VortexCLI::VortexCLICallbacks::serialAvail() +{ + return g_pVortexCLI->m_virtualSerial ? g_pVortexCLI->m_virtualSerial->avail() : 0; +} + +size_t VortexCLI::VortexCLICallbacks::serialRead(char *buf, size_t amt) +{ + return g_pVortexCLI->m_virtualSerial ? g_pVortexCLI->m_virtualSerial->read(buf, amt) : 0; +} + +uint32_t VortexCLI::VortexCLICallbacks::serialWrite(const uint8_t *buf, size_t amt) +{ + return g_pVortexCLI->m_virtualSerial ? g_pVortexCLI->m_virtualSerial->write(buf, amt) : 0; +} + +bool VortexCLI::VortexCLICallbacks::serialConnectedReal() +{ + return g_pVortexCLI->isEditorConnected(); +} + +// --------------------------------------------------------------------------- +// socat bridge +// --------------------------------------------------------------------------- + +#ifndef WASM + +// resolve the USB vendor id of a tty device by walking its sysfs entry up to +// the parent USB device (ex: returns "28de" for the Steam Controller Puck) +static std::string ttyUsbVendor(const char *ttyName) +{ + std::string sysLink = std::string("/sys/class/tty/") + ttyName; + char resolved[4096]; + ssize_t len = readlink(sysLink.c_str(), resolved, sizeof(resolved) - 1); + if (len <= 0) { + return ""; + } + resolved[len] = '\0'; + std::string dir = std::string("/sys/class/tty/") + resolved; + while (true) { + std::string vendor = dir + "/idVendor"; + FILE *f = fopen(vendor.c_str(), "r"); + if (f) { + char buf[16] = {0}; + if (fgets(buf, sizeof(buf), f)) { + fclose(f); + std::string v = buf; + if (v.size() > 0 && v[v.size() - 1] == '\n') { + v.pop_back(); + } + return v; + } + fclose(f); + } + size_t slash = dir.rfind('/'); + if (slash == std::string::npos || slash == 0) { + break; + } + dir = dir.substr(0, slash); + } + return ""; +} + +// udev usually creates serial ports as root-owned mode 660 (group +// dialout/uucp), which a normal user's browser cannot open. widen it to 666 +// so Web Serial can see it, this needs root or dialout/uucp membership. the +// gadget setup script already does this, so only warn when it's still locked. +static void chmodIfLocked(const char *path) +{ + struct stat st; + if (stat(path, &st) == 0 && (st.st_mode & 0666) == 0666) { + return; // already world-accessible (gadget script or udev rule) + } + if (chmod(path, 0666) != 0) { + printf("WARNING: could not chmod 666 %s (%s)\n", path, strerror(errno)); + printf(" the browser can only open it if you are in the dialout/uucp group\n"); + printf(" or a udev rule grants access, for example (get ids from lsusb):\n"); + printf(" SUBSYSTEM==\"tty\", ATTRS{idVendor}==\"1a86\", ATTRS{idProduct}==\"7523\", MODE=\"0666\"\n"); + } +} + +std::string VortexCLI::findUsbSerialDevice() +{ + // the browser's Web Serial can only enumerate real serial devices, look for + // a USB-TTL adapter (or gadget endpoint) to bridge the pty to. ttyGS* is the + // device side of a synthesized USB gadget (see setupVirtualGadget): the + // engine talks to it and the browser opens the host-side /dev/ttyACMx. + for (const char *pattern : {"/dev/ttyGS", "/dev/ttyUSB", "/dev/ttyACM"}) { + for (int i = 0; i < 32; ++i) { + char path[128]; + snprintf(path, sizeof(path), "%s%d", pattern, i); + struct stat st; + if (stat(path, &st) != 0 || !S_ISCHR(st.st_mode)) { + continue; + } + // skip known-unsuitable devices, like the Valve Steam Controller Puck + // (a bluetooth receiver whose ACM port is not a usable UART) + std::string vendor = ttyUsbVendor(path + 5); + if (vendor == "28de") { + continue; + } + return path; + } + } + return ""; +} + +#endif // !WASM + +// one-time root setup that synthesizes a USB serial device in the kernel with +// no physical hardware: dummy_hcd provides a virtual USB host+gadget pair and +// a composite ACM gadget becomes the port. the host side (/dev/ttyACMx) is what +// the browser's Web Serial opens, the gadget side (/dev/ttyGS0) is what the +// engine talks to. these run inside the tool itself (elevated via sudo) rather +// than via an external shell script. +#ifndef WASM +static bool gadgetWrite(const std::string &path, const std::string &value) +{ + int fd = open(path.c_str(), O_WRONLY | O_CLOEXEC); + if (fd < 0) { + return false; + } + ssize_t n = write(fd, value.c_str(), value.size()); + close(fd); + return n == (ssize_t)value.size(); +} + +static bool gadgetDirExists(const std::string &path) +{ + struct stat st; + return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +} + +static std::string findUdc() +{ + DIR *d = opendir("/sys/class/udc"); + if (!d) { + return ""; + } + std::string name; + struct dirent *e = nullptr; + while ((e = readdir(d)) != nullptr) { + if (e->d_name[0] == '.') { + continue; + } + name = e->d_name; + break; + } + closedir(d); + return name; +} + +// build the composite ACM gadget tree in configfs. idempotent: if the tree is +// already present it only makes sure the ports are world-writable. +static const char *kGadgetUdevRule = "/etc/udev/rules.d/99-vortex-gadget.rules"; + +// install a udev rule granting world access to our gadget's tty nodes. without +// it the default tty rule puts them in the uucp group at 0660 and udev re-applies +// that on later events, silently undoing any chmod we do. +static void installGadgetUdevRule() +{ + FILE *f = fopen(kGadgetUdevRule, "w"); + if (!f) { + return; + } + fputs("# Vortex Engine virtual USB gadget: make the tty nodes world-writable\n", f); + // ttyGS* is the gadget/device side: it does not expose the usb idVendor/Product + // attributes (those live in the configfs gadget dir, not on the udev parent + // chain), so match it by name alone. ttyGS* only ever exists while a gadget + // function is bound, so this is safe. + fputs("SUBSYSTEM==\"tty\", KERNEL==\"ttyGS*\", MODE=\"0666\"\n", f); + fputs("SUBSYSTEM==\"tty\", KERNEL==\"ttyACM*\", ATTRS{idVendor}==\"1d6b\", " + "ATTRS{idProduct}==\"0104\", MODE=\"0666\"\n", f); + fclose(f); + // make udev pick it up and re-apply permissions to any matching nodes + system("udevadm control --reload-rules 2>/dev/null"); + system("udevadm trigger --subsystem-match=tty 2>/dev/null"); + system("udevadm settle 2>/dev/null"); +} + +static bool doGadgetSetup() +{ + const std::string G = "/sys/kernel/config/usb_gadget/vortex"; + + // load the kernel pieces (best-effort; they may already be built in) + system("modprobe dummy_hcd 2>/dev/null"); + system("modprobe libcomposite 2>/dev/null"); + system("modprobe usb_f_acm 2>/dev/null"); + + // get our udev rule in place before the nodes appear so 0666 sticks + installGadgetUdevRule(); + + if (!gadgetDirExists(G)) { + mkdir(G.c_str(), 0755); + mkdir((G + "/strings/0x409").c_str(), 0755); + mkdir((G + "/configs/c.1").c_str(), 0755); + mkdir((G + "/configs/c.1/strings/0x409").c_str(), 0755); + mkdir((G + "/functions/acm.usb0").c_str(), 0755); + gadgetWrite(G + "/idVendor", "0x1d6b"); + gadgetWrite(G + "/idProduct", "0x0104"); + gadgetWrite(G + "/strings/0x409/manufacturer", "lightshow.lol"); + gadgetWrite(G + "/strings/0x409/product", "Vortex Engine"); + gadgetWrite(G + "/configs/c.1/strings/0x409/configuration", "Virtual Serial"); + symlink((G + "/functions/acm.usb0").c_str(), (G + "/configs/c.1/acm.usb0").c_str()); + } + + // bind the first available UDC if not already bound + bool bound = false; + { + FILE *udcFile = fopen((G + "/UDC").c_str(), "r"); + if (udcFile) { + char buf[128] = {0}; + if (fgets(buf, sizeof(buf), udcFile) && buf[0] != '\0' && buf[0] != '\n') { + bound = true; + } + fclose(udcFile); + } + } + if (!bound) { + std::string udc = findUdc(); + if (udc.empty()) { + fprintf(stderr, "no UDC available, dummy_hcd did not load\n"); + return false; + } + gadgetWrite(G + "/UDC", udc); + } + + // wait for the ports to appear (host enumeration can lag) and make them + // world-writable as they come up, so the browser can open them. host-side + // enumeration on dummy_hcd can take several seconds, so give it a generous + // window rather than reporting right away. + for (int i = 0; i < 150; ++i) { + if (access("/dev/ttyGS0", F_OK) == 0) { + chmod("/dev/ttyGS0", 0666); + } + bool anyAcm = false; + for (int a = 0; a < 32; ++a) { + std::string acm = "/dev/ttyACM" + std::to_string(a); + if (access(acm.c_str(), F_OK) != 0) { + continue; + } + chmod(acm.c_str(), 0666); + anyAcm = true; + } + if (access("/dev/ttyGS0", F_OK) == 0 && anyAcm) { + break; + } + usleep(100000); + } + + // final pass: anything that appeared right as the loop ended still needs the + // world-writable bit so the unprivileged socat/browser can open it (we are + // root here, so ignore failures on ports that do not exist yet) + chmod("/dev/ttyGS0", 0666); + for (int a = 0; a < 32; ++a) { + chmod(("/dev/ttyACM" + std::to_string(a)).c_str(), 0666); + } + return true; +} + +// remove the composite ACM gadget so the ttyACM* it created goes away. +static bool doGadgetTeardown() +{ + const std::string G = "/sys/kernel/config/usb_gadget/vortex"; + if (gadgetDirExists(G)) { + // unbind the UDC first, otherwise the configs/functions below are busy + gadgetWrite(G + "/UDC", "\n"); + usleep(500000); // give the driver a moment to release the endpoints + unlink((G + "/configs/c.1/acm.usb0").c_str()); + rmdir((G + "/configs/c.1/strings/0x409").c_str()); + rmdir((G + "/configs/c.1").c_str()); + rmdir((G + "/functions/acm.usb0").c_str()); + rmdir((G + "/strings/0x409").c_str()); + rmdir(G.c_str()); + } + // the utility gadgets are gone now, so drop the udev rule we installed and + // reload so it does not linger on the system after the tool stops + if (unlink(kGadgetUdevRule) == 0 || errno != ENOENT) { + system("udevadm control --reload-rules 2>/dev/null"); + system("udevadm trigger --subsystem-match=tty 2>/dev/null"); + system("udevadm settle 2>/dev/null"); + } + return true; +} + +// spawn `sudo ` and wait for it to finish, running the +// configfs operations above as root. +// +// the binary path must be resolved to a real absolute path BEFORE handing it +// to sudo: `/proc/self/exe` is a magic symlink resolved in the context of the +// process that reads it, and after fork/exec that process is sudo itself, so +// sudo would end up running itself with our flag as one of its own options. +static int runSudoSelf(const char *modeArg) +{ + char self[4096]; + ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1); + if (n <= 0) { + return -1; + } + self[n] = '\0'; + + // sudo has to read the (possibly interactive) password from a sane, + // blocking terminal. our run-loop terminal is raw + O_NONBLOCK, which + // makes every password prompt fail no matter what is typed, so restore + // the terminal for the duration of the call and re-apply it after + bool wasNonblock = s_terminalNonblock; + if (wasNonblock) { + restore_terminal(); + } + + pid_t pid = fork(); + if (pid < 0) { + if (wasNonblock) { + set_terminal_nonblocking(); + } + return -1; + } + if (pid == 0) { + execlp("sudo", "sudo", self, modeArg, (char *)nullptr); + _exit(127); + } + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + if (wasNonblock) { + set_terminal_nonblocking(); + } + return -1; + } + if (wasNonblock) { + set_terminal_nonblocking(); + } + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +} +#endif // !WASM + +bool VortexCLI::setupVirtualGadget() +{ + if (m_gadgetAttempted) { + return m_gadgetReady; + } + m_gadgetAttempted = true; +#ifndef WASM + // perform the gadget setup inside the tool itself, elevated via sudo + int rc = runSudoSelf("--gadget-setup"); + if (rc != 0) { + printf("Could not create a virtual USB serial device (the setup needs sudo).\n"); + printf(" run the tool with sudo once to synthesize it, or plug in a USB-TTL dongle instead\n"); + m_gadgetReady = false; + return false; + } + m_gadgetReady = true; + // host-side enumeration can lag a moment after the gadget comes up, so retry + // before reporting instead of alarming the user that the port is missing + // when it is about to show up + std::string hostStr; + for (int attempt = 0; attempt < 25 && hostStr.empty(); ++attempt) { + for (int i = 0; i < 32; ++i) { + char acm[64]; + snprintf(acm, sizeof(acm), "/dev/ttyACM%d", i); + if (access(acm, F_OK) == 0 && ttyUsbVendor(acm + 5) != "28de") { + hostStr = acm; + break; + } + } + if (hostStr.empty()) { + usleep(200000); + } + } + if (hostStr.empty() && access("/dev/ttyGS0", F_OK) != 0) { + printf("WARNING: gadget created but /dev/ttyGS0 is missing\n"); + } else { + // report the state so the user knows which port the browser should list + printf("Virtual USB serial device up: engine->/dev/ttyGS0, browser->%s\n", + hostStr.empty() ? "/dev/ttyACM? (none yet)" : hostStr.c_str()); + if (hostStr.empty()) { + printf(" WARNING: no host-side ttyACM appeared; Web Serial will not show it\n"); + } + } + return true; +#else + return false; +#endif +} + +void VortexCLI::teardownVirtualGadget() +{ +#ifndef WASM + if (!m_usingVirtualGadget && !m_gadgetReady) { + // we never set the gadget up or bridged one, so there is nothing of ours + // to remove + return; + } + // restore the terminal first: after ctrl-c stdin is still in raw mode and the + // sudo password prompt won't work, so tear the gadget down on a sane terminal + restore_terminal(); + int rc = runSudoSelf("--gadget-teardown"); + m_gadgetAttempted = false; + m_gadgetReady = false; + m_usingVirtualGadget = false; + if (rc != 0) { + printf("WARNING: could not tear down the virtual USB serial device\n"); + } else { + printf("Virtual USB serial device torn down\n"); + } +#endif +} + +bool VortexCLI::startSocatBridge() +{ + if (m_bridgeStarted) { + return true; + } + if (!m_virtualSerial || m_virtualSerial->path() == nullptr) { + return false; + } +#ifdef WASM + return false; +#else + // the pty is what the engine talks to, socat relays it to the real device. + // VORTEX_BRIDGE_DEVICE overrides auto-detection (useful for testing and for + // picking a specific port when several adapters are plugged in) + std::string device; + const char *bridgeOverride = getenv("VORTEX_BRIDGE_DEVICE"); + if (bridgeOverride && bridgeOverride[0] != '\0') { + device = bridgeOverride; + } else { + device = findUsbSerialDevice(); + if (device.compare(0, 10, "/dev/ttyGS") == 0) { + // this is the device side of our synthesized gadget (whether created just + // now or left over by an earlier run): always (re-)run the idempotent root + // setup so both sides are world-writable, otherwise socat/the browser get + // a root:uucp 660 port it cannot open + setupVirtualGadget(); + } else if (device.empty() && setupVirtualGadget()) { + // no physical dongle: the kernel gadget now provides a real serial port + device = findUsbSerialDevice(); + } + } + if (device.empty()) { + return false; + } + // bridging the device side of a synthesized gadget means we own that gadget, + // even if a previous run created it, so it must be torn down on exit + if (device.compare(0, 10, "/dev/ttyGS") == 0) { + m_usingVirtualGadget = true; + } + // widen permissions on the bridge target. when bridging the device side of + // the synthesized gadget (ttyGS0) the browser opens the host side (ttyACMx), + // so that needs opening up too. + chmodIfLocked(device.c_str()); + if (device.compare(0, 10, "/dev/ttyGS") == 0) { + for (int i = 0; i < 32; ++i) { + char acm[64]; + snprintf(acm, sizeof(acm), "/dev/ttyACM%d", i); + struct stat st; + if (stat(acm, &st) == 0 && S_ISCHR(st.st_mode) && ttyUsbVendor(acm + 5) != "28de") { + chmodIfLocked(acm); + } + } + } + std::string optStr = device + ",b115200,raw,echo=0,clocal=1"; + pid_t pid = fork(); + if (pid < 0) { + printf("Failed to fork socat bridge\n"); + return false; + } + if (pid == 0) { + // child: exec socat, replace our image with it + execlp("socat", "socat", m_virtualSerial->path(), optStr.c_str(), nullptr); + _exit(127); + } + m_socatPid = pid; + m_bridgeStarted = true; + m_bridgeDevice = device; + printf("Bridging %s to %s via socat\n", m_virtualSerial->path(), device.c_str()); + return true; +#endif +} + +void VortexCLI::pollBridge() +{ +#ifndef WASM + // if socat died (device unplugged) or never started, keep looking for a + // USB serial device silently so the user can plug one in after startup + if (m_bridgeStarted) { + int status = 0; + if (m_socatPid > 0 && waitpid(m_socatPid, &status, WNOHANG) == m_socatPid) { + m_socatPid = -1; + m_bridgeStarted = false; + } + } + if (m_bridgeStarted) { + return; + } + // don't hammer the filesystem on every tick, scan once a second + uint32_t now = Time::getCurtime(); + if (m_bridgeScanTick && (now - m_bridgeScanTick) < 1000) { + return; + } + m_bridgeScanTick = now; + startSocatBridge(); +#endif +} + +void VortexCLI::updateHostConnection() +{ +#ifndef WASM + if (!m_bridgeStarted || m_bridgeDevice.empty()) { + // nothing is bridged yet, so nobody can possibly be attached + m_hostConnected = false; + return; + } + // don't rescan /proc on every tick + uint32_t now = Time::getCurtime(); + if (m_hostCheckTick && (now - m_hostCheckTick) < 100) { + return; + } + m_hostCheckTick = now; + // the browser opens the HOST side of the gadget (/dev/ttyACM*) while socat + // holds the DEVICE side (/dev/ttyGS*), so every node that counts as + // 'someone is attached' has to be collected first: the bridged device plus + // (when bridging our own gadget) every real ttyACM it exposes to the host + std::vector nodes; + nodes.push_back(m_bridgeDevice); + if (m_bridgeDevice.compare(0, 10, "/dev/ttyGS") == 0) { + for (int i = 0; i < 32; ++i) { + char acm[64]; + snprintf(acm, sizeof(acm), "/dev/ttyACM%d", i); + if (access(acm, F_OK) == 0 && ttyUsbVendor(acm + 5) != "28de") { + nodes.push_back(acm); + } + } + } + // the only reliable way to know whether Web Serial actually opened the + // port: check whether any process holds the device node open. the gadget + // kernels don't expose the host's DTR through TIOCMGET, and the browser + // is just another local process anyway + bool opened = false; + if (DIR *proc = opendir("/proc")) { + while (struct dirent *e = readdir(proc)) { + if (e->d_name[0] < '0' || e->d_name[0] > '9') { + continue; + } + int pid = atoi(e->d_name); + // skip ourselves and our own socat bridge, socat obviously has the + // bridged device open but it isn't the editor + if (pid == m_socatPid || pid == (int)getpid()) { + continue; + } + std::string fddir = std::string("/proc/") + e->d_name + "/fd"; + DIR *fds = opendir(fddir.c_str()); + if (!fds) { + continue; // permission denied (other users' processes) etc + } + while (struct dirent *f = readdir(fds)) { + char target[256]; + ssize_t len = readlink((fddir + "/" + f->d_name).c_str(), target, sizeof(target) - 1); + if (len <= 0) { + continue; + } + target[len] = '\0'; + for (const std::string &node : nodes) { + if (node == target) { + opened = true; + break; + } + } + if (opened) { + break; + } + } + closedir(fds); + if (opened) { + break; + } + } + closedir(proc); + } + m_hostConnected = opened; +#endif +} + +bool VortexCLI::isEditorConnected() +{ + if (!m_virtualSerial || !m_virtualSerial->hasClient()) { + return false; + } + return m_hostConnected; +} + // main function to run the CLI int main(int argc, char *argv[]) { + // hidden elevated modes that perform the virtual USB gadget setup/teardown + // in-process: the running (user) instance re-execs itself as root for these + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--gadget-setup") == 0) { +#ifdef WASM + return 1; +#else + return doGadgetSetup() ? 0 : 1; +#endif + } + if (strcmp(argv[i], "--gadget-teardown") == 0) { +#ifdef WASM + return 1; +#else + return doGadgetTeardown() ? 0 : 1; +#endif + } + } VortexCLI cli; cli.init(argc, argv); #ifndef WASM diff --git a/VortexEngine/VortexCLI/VortexCLI.h b/VortexEngine/VortexCLI/VortexCLI.h index 4a964b02fd..df9aee1056 100644 --- a/VortexEngine/VortexCLI/VortexCLI.h +++ b/VortexEngine/VortexCLI/VortexCLI.h @@ -8,6 +8,8 @@ #include "Colors/Colorset.h" #include "Leds/LedTypes.h" +#include "VirtualSerial.h" + class VortexCLI { public: @@ -49,6 +51,12 @@ class VortexCLI virtual long checkPinHook(uint32_t pin) override; virtual void ledsInit(void *cl, int count) override; virtual void ledsShow() override; + virtual bool serialCheck() override; + virtual void serialBegin(uint32_t baud) override; + virtual int32_t serialAvail() override; + virtual size_t serialRead(char *buf, size_t amt) override; + virtual uint32_t serialWrite(const uint8_t *buf, size_t amt) override; + virtual bool serialConnectedReal() override; private: // receive a message from client }; @@ -56,6 +64,28 @@ class VortexCLI // internal helper for updating terminal size void get_terminal_size(); + // find a USB serial device (/dev/ttyUSB*, /dev/ttyACM*) that the browser's + // Web Serial can enumerate, returns empty string when none is present + std::string findUsbSerialDevice(); + // synthesize a virtual USB serial device in the kernel (dummy_hcd + + // composite acm gadget) so the browser has a real port to open even with no + // physical hardware, needs sudo once per boot + bool setupVirtualGadget(); + // undo the synthesized gadget so the ttyACM* it created goes away when the + // tool exits, needs sudo once per boot + void teardownVirtualGadget(); + // spawn socat to bridge the virtual port to a USB serial device, returns + // false if socat is missing or the bridge could not be started + bool startSocatBridge(); + // called every run loop tick while waiting for a device to be plugged in + void pollBridge(); + // samples the modem status lines of the bridged gadget device so we can + // tell whether Web Serial actually opened the host-side port (DTR) + void updateHostConnection(); + // whether the editor is truly reachable end-to-end: pty client attached + // AND (for the synthesized gadget) the browser has the port open + bool isEditorConnected(); + // these are in no particular order RGBColor *m_ledList; uint32_t m_numLeds; @@ -102,6 +132,28 @@ class VortexCLI std::string m_patternIDStr; std::string m_colorsetStr; std::string m_argumentsStr; + // virtual serial device support, emulates a USB-connected device so the + // editor website can connect to the engine over a real com port + bool m_editorMode; + VirtualSerial *m_virtualSerial; + // automatic socat bridge between the virtual port and a real USB serial + // device (the only thing the browser's Web Serial can enumerate) + bool m_bridgeStarted; + int m_socatPid; + uint32_t m_bridgeScanTick; + // virtual USB gadget (dummy_hcd) synthesis state + bool m_gadgetAttempted; + bool m_gadgetReady; + // set once we bridge the device side (ttyGS*) of a synthesized gadget, even + // if the gadget itself was created by an earlier run; makes teardown remove + // it on exit instead of leaving the ttyACM* behind + bool m_usingVirtualGadget; + // the device the socat bridge relays to (ex: /dev/ttyGS0) + std::string m_bridgeDevice; + // whether any process (i.e. Web Serial in the browser) actually has the + // bridged device open, detected by scanning /proc/*/fd + bool m_hostConnected; + uint32_t m_hostCheckTick; // to pipe stuff into the engine int m_pipe_fd[2]; int m_saved_stdin; diff --git a/VortexEngine/VortexLib/VortexLib.h b/VortexEngine/VortexLib/VortexLib.h index dd58ccef30..3ca4affd43 100644 --- a/VortexEngine/VortexLib/VortexLib.h +++ b/VortexEngine/VortexLib/VortexLib.h @@ -55,6 +55,10 @@ class VortexCallbacks virtual size_t serialRead(char *buf, size_t amt) { return 0; } // called when engine writes to serial, use this to read data from the vortex engine virtual uint32_t serialWrite(const uint8_t *buf, size_t amt) { return 0; } + // called when engine checks whether the serial connection is still alive, this + // is used to detect disconnects so the editor connection can be re-established + // on plug back in. Return true if the connection is still active. + virtual bool serialConnectedReal() { return true; } // called when the LED strip is initialized virtual void ledsInit(void *cl, int count) { } // called when the brightness is changed diff --git a/VortexEngine/src/Serial/Serial.cpp b/VortexEngine/src/Serial/Serial.cpp index fdc261a406..7f59e4f67d 100644 --- a/VortexEngine/src/Serial/Serial.cpp +++ b/VortexEngine/src/Serial/Serial.cpp @@ -37,7 +37,11 @@ void SerialComs::cleanup() bool SerialComs::isConnected() { -#ifdef VORTEX_EMBEDDED +#if defined(VORTEX_EMBEDDED) || defined(VORTEX_LIB) + // if the real connection is lost then reset the cached connected state so + // that the connection can be detected again the next time it comes back. + // this is how the embedded builds behave and virtual serial devices rely on + // it so they can re-greet the editor when a new client plugs in. if (!isConnectedReal()) { m_serialConnected = false; return false; @@ -54,7 +58,9 @@ bool SerialComs::isConnectedReal() #ifdef VORTEX_EMBEDDED bool currentState = HWCDCSerial.isConnected(); #else - bool currentState = true; + // under VORTEX_LIB the 'real' connection state is provided by the callbacks + // so a virtual serial device can simulate a device being plugged/unplugged + bool currentState = Vortex::vcallbacks()->serialConnectedReal(); #endif unsigned long currentTime = Time::getCurtime(); diff --git a/VortexEngine/src/Storage/Storage.cpp b/VortexEngine/src/Storage/Storage.cpp index 6b4ff15988..e53bdaba3f 100644 --- a/VortexEngine/src/Storage/Storage.cpp +++ b/VortexEngine/src/Storage/Storage.cpp @@ -153,7 +153,13 @@ bool Storage::write(uint16_t slot, ByteStream &buffer) } CloseHandle(hFile); #else - FILE *f = fopen(STORAGE_FILENAME, "w"); + // open without truncating: the file holds every profile's data and only a + // slot-sized region is rewritten, "w" would wipe all other profiles + FILE *f = fopen(STORAGE_FILENAME, "r+"); + if (!f) { + // fresh file, nothing to preserve yet + f = fopen(STORAGE_FILENAME, "w+"); + } if (!f) { return false; }