From 669e0d54c2e51f5393b375244116d4e5b65c9121 Mon Sep 17 00:00:00 2001 From: Andrey Parfenov Date: Tue, 27 Oct 2026 08:47:16 +0100 Subject: [PATCH 1/2] Add native USB serial discovery for Cyton and Cyton Daisy --- .github/workflows/serial_discovery.yml | 27 ++ cmake/serial_port_info.cmake | 8 + docs/SerialPortDiscovery.rst | 169 +++++++++ docs/SupportedBoards.rst | 7 + docs/index.rst | 1 + src/board_controller/build.cmake | 5 + .../openbci/inc/openbci_serial_helpers.h | 11 + .../openbci/openbci_serial_board.cpp | 87 ++--- .../openbci/openbci_serial_helpers.cpp | 69 ++++ src/tests/serial/CMakeLists.txt | 24 ++ src/tests/serial/openbci_serial_pty.py | 111 ++++++ src/tests/serial/serial_discovery_tests.cpp | 296 ++++++++++++++++ src/utils/inc/serial.h | 17 +- src/utils/inc/serial_port_info.h | 16 + src/utils/serial_port_info.cpp | 328 ++++++++++++++++++ 15 files changed, 1135 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/serial_discovery.yml create mode 100644 cmake/serial_port_info.cmake create mode 100644 docs/SerialPortDiscovery.rst create mode 100644 src/board_controller/openbci/inc/openbci_serial_helpers.h create mode 100644 src/board_controller/openbci/openbci_serial_helpers.cpp create mode 100644 src/tests/serial/CMakeLists.txt create mode 100644 src/tests/serial/openbci_serial_pty.py create mode 100644 src/tests/serial/serial_discovery_tests.cpp create mode 100644 src/utils/inc/serial_port_info.h create mode 100644 src/utils/serial_port_info.cpp diff --git a/.github/workflows/serial_discovery.yml b/.github/workflows/serial_discovery.yml new file mode 100644 index 000000000..a0449a0ae --- /dev/null +++ b/.github/workflows/serial_discovery.yml @@ -0,0 +1,27 @@ +name: Serial discovery + +on: [push, pull_request] + +jobs: + native-tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-2022, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Configure hardware-independent serial tests + run: cmake -S src/tests/serial -B build/serial-tests -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build/serial-tests --config Release --parallel 2 + - name: Test + run: ctest --test-dir build/serial-tests -C Release --output-on-failure --timeout 30 + - name: Build BoardController for POSIX integration + if: runner.os == 'Linux' + run: | + cmake -S . -B build/serial-integration -DBUILD_SYNCHRONI_SDK=OFF -DBRAINFLOW_COPY_TO_PACKAGE_DIRS=OFF -DWARNINGS_AS_ERRORS=ON + cmake --build build/serial-integration --target BoardController --parallel 2 + - name: Test OpenBCI startup through pseudo terminals + if: runner.os == 'Linux' + run: python3 src/tests/serial/openbci_serial_pty.py compiled/libBoardController.so diff --git a/cmake/serial_port_info.cmake b/cmake/serial_port_info.cmake new file mode 100644 index 000000000..b4192fe14 --- /dev/null +++ b/cmake/serial_port_info.cmake @@ -0,0 +1,8 @@ +# Native metadata enumeration only; no new redistributable library. +function (link_serial_port_info target) + if (WIN32) + target_link_libraries (${target} PRIVATE setupapi cfgmgr32 advapi32) + elseif (APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS") + target_link_libraries (${target} PRIVATE "-framework IOKit" "-framework CoreFoundation") + endif () +endfunction () diff --git a/docs/SerialPortDiscovery.rst b/docs/SerialPortDiscovery.rst new file mode 100644 index 000000000..20ac4ccb4 --- /dev/null +++ b/docs/SerialPortDiscovery.rst @@ -0,0 +1,169 @@ +Serial port discovery +===================== + +Scope and API +------------- + +This is the first implementation of `issue #187 `_: +native USB serial enumeration for Windows, Linux and macOS, with automatic selection for +``CYTON_BOARD`` and ``CYTON_DAISY_BOARD``. Other serial boards still require an explicit port. + +For these two board IDs: + +* A nonempty ``BrainFlowInputParams.serial_port`` always takes precedence and bypasses discovery, + including when ``serial_number`` is also set. Existing OS port paths and optional libftdi URLs work + as before. +* An empty ``serial_port`` enumerates USB serial metadata without opening ports, then filters for + Cyton's FT231X dongle (VID ``0403``, PID ``6015``). +* A nonempty ``serial_number`` additionally requires an exact match to the OS-reported **USB dongle** + serial number. This is not the Cyton radio channel or a serial number from its firmware. +* Exactly one matching port is required. Zero matches, ambiguity or enumeration failure return + ``INVALID_ARGUMENTS_ERROR`` before opening any port. Ambiguous candidates and their serial numbers + are logged; choose an explicit port or a unique dongle serial. Missing or duplicated serial numbers + cannot resolve ambiguity. An already busy candidate is not silently skipped. +* Only the selected port is opened, once through the normal OS serial path. Its normal handshake must + succeed. If the device disappears, the open/handshake error is returned without trying another port. + +The FT231X IDs describe a USB adapter also used by unrelated products. They do not establish that a +Cyton is attached or powered on. Use an explicit port or known USB serial number on machines with +other FT231X equipment. Clones, reprogrammed IDs and adapters without an OS serial driver require an +explicit port. This implementation does not scan radio channels or identify Cyton versus Daisy. + +No C ABI, input JSON fields, exit-code values, board-description schema or language-binding signatures +are added. ``serial_number`` gains the above meaning only for the two supported board IDs when the +port is empty. There is no public candidate-list API in this first increment. The resolved port stays +local to preparation, preserving the caller's session key and allowing a fresh search after failure. + +Example with two dongles (replace the serials with the values reported for your devices):: + + a = BrainFlowInputParams() + a.serial_number = "DONGLE_A" + b = BrainFlowInputParams() + b.serial_number = "DONGLE_B" + first = BoardShim(BoardIds.CYTON_BOARD, a) + second = BoardShim(BoardIds.CYTON_BOARD, b) + first.prepare_session() + second.prepare_session() + +Discovery never chooses by enumeration index: COM numbers, USB paths and enumeration order can change +after reconnection. Distinct USB serials or explicit ports are needed for repeatable multi-board use. +There is no new cross-session device reservation mechanism; normal session and OS-open checks apply. + +Architecture decision +--------------------- + +``Serial::get_port_info`` is an internal C++ utility, returning port path, VID, PID and optional USB +serial number. It returns a sorted, deduplicated snapshot and distinguishes an empty list from a +failed/unsupported enumeration. It neither opens serial handles nor sends commands. + +.. list-table:: Enumeration alternatives + :header-rows: 1 + :widths: 22 35 43 + + * - Option + - Advantages + - Costs / decision + * - Native metadata backends (chosen) + - Fits the existing C++11 serial abstraction; no extra redistributable dependency + - Three small platform backends need CI and hardware verification + * - libserialport + - Existing enumeration and USB metadata support on all three desktop platforms + - Another library to build/package in every distributed binary; LGPL3+ distribution requirements; + duplicates serial I/O already provided by OSSerial and optional LibFTDISerial + * - Per-binding enumeration, e.g. pyserial + - Convenient for Python-only applications + - Adds a binding-specific dependency and leaves other languages inconsistent + * - Open each port and probe it + - Can work without USB metadata + - Changes unrelated device state, resets adapters and creates races; rejected + +The `upstream libserialport README `_ +documents its supported platforms and license. This implementation does not use its code. + +.. list-table:: Native backends + :header-rows: 1 + :widths: 20 45 35 + + * - Platform + - Implementation + - Build/runtime requirements + * - Windows + - SetupAPI present Ports devices, registry PortName, Config Manager USB-parent traversal; + only unique physical USB instance IDs supply a serial number + - System setupapi, cfgmgr32 and advapi32 libraries; installed VCP driver + * - Linux + - /sys/class/tty device ancestry and USB idVendor/idProduct/serial attributes; existing /dev nodes + - libc/sysfs only, no libudev; serial permissions are checked at normal open + * - macOS + - IOKit IOSerialBSDClient callout path and physical USB-parent properties + - System IOKit/CoreFoundation frameworks; only /dev/cu.* aliases, avoiding tty/cu duplicates + * - Other targets, including Android/iOS + - Explicit port path remains available; automatic enumeration reports unsupported + - No desktop IOKit dependency is introduced into iOS builds + +USB identity belongs to the transport policy in ``openbci_serial_helpers.cpp``, outside the publicly +returned board-description JSON in ``brainflow_boards.cpp``. One C++ implementation serves all +bindings. Promoting transport descriptors into public board metadata now would imply more stable and +complete device identities than a shared FTDI VID/PID provides. A future public enumeration API can +be designed separately if applications need structured candidate lists. + +Further boards need independently verified descriptors and selection semantics. In particular, +Ganglion has a separate dynamically loaded BLED112 path and its ``serial_number`` already selects the +BLE device. FreeEEG has multiple variants and needs verified USB identities. Do not apply the Cyton +USB-serial interpretation globally or infer descriptors from board names. Historical dongle examples +are recorded in the `OpenBCI support discussion `_. + +Startup deadline and buffer policy +---------------------------------- + +The selected port is configured as before. Input is flushed before sending ``v``, never while waiting +for its welcome. A flush failure is logged, preserving compatibility with older optional libftdi +versions without a flush implementation. + +Welcome parsing uses ``steady_clock`` and accepts three consecutive ``$`` bytes, including fragmented +reads. Empty reads do not reset the deadline and include a short backoff. Continuous garbage cannot +extend the deadline; diagnostic storage is capped at 4096 bytes, independently of parsing. +Explicit negative read errors stop immediately (OSSerial currently maps OS read errors to empty reads). + +``timeout=0`` uses a six-second welcome deadline; values 1 through 600 specify seconds. Other values +return ``INVALID_ARGUMENTS_ERROR``. This gives the previously unused Cyton timeout field a defined +meaning, including for explicit ports. The deadline controls the welcome, not the entire preparation. +A pending synchronous read may overrun it by one configured read timeout (about 1--1.1 seconds with the +current OS backend); responses arriving after the deadline are rejected. + +Verification +------------ + +Run the dependency-free tests with:: + + cmake -S src/tests/serial -B build/serial-tests -DCMAKE_BUILD_TYPE=Release + cmake --build build/serial-tests --config Release + ctest --test-dir build/serial-tests -C Release --output-on-failure --timeout 30 + +They cover zero/one/two matches, reversed order, unrelated adapters, missing IDs, duplicate paths, +missing/duplicate USB serials, exact serial filtering, Windows USB instance parsing, fragmented and +delayed welcome messages, read errors, deadline expiry and bounded diagnostics. Linux fixtures cover +USB ancestry, disappearing ports and malformed attributes; a FIFO port proves enumeration does not +open the device. CI compiles and runs these tests on Windows, Linux and macOS. + +The POSIX integration test exercises actual ``prepare_session``/``release_session`` with Cyton and +Daisy pseudo terminals, explicit-port precedence, invalid arguments, failed-handshake retry and two +prepared sessions. Run it against a locally built library, for example:: + + python3 src/tests/serial/openbci_serial_pty.py compiled/libBoardController.so + +Hardware acceptance checks, required separately from the automated tests: + +1. On each desktop OS, verify the returned VID/PID and USB serial against system tools for one real + dongle, then prepare/start/stop/release with an empty port. +2. Attach two dongles: no filter must fail without opening either; distinct serials must select the + intended devices. Reverse connection order and repeat. Duplicate/absent serials must require paths. +3. Keep one dongle busy, unplug one between enumeration and open, and test insufficient permissions: + there must be no automatic fallback to another device. +4. Verify macOS callout-only enumeration and Windows FTDI parent serials with both available VCP + driver variants; test Linux ttyUSB and USB CDC ancestry. +5. With a powered-off Cyton or delayed welcome, preparation must fail within the configured welcome + deadline plus one read, and a subsequent retry must release/reopen resources correctly. + +Automated tests cannot establish USB-driver metadata correctness or radio/hardware behaviour. This +increment deliberately leaves #187 open for other serial-board families and physical-device validation. diff --git a/docs/SupportedBoards.rst b/docs/SupportedBoards.rst index d467e0d49..cc5b10e6e 100644 --- a/docs/SupportedBoards.rst +++ b/docs/SupportedBoards.rst @@ -230,6 +230,10 @@ To create such board you need to specify the following board ID and fields of Br - :code:`BoardIds.CYTON_BOARD` - :code:`serial_port`, e.g. COM3, /dev/ttyUSB0, etc +On Windows, Linux and macOS, ``serial_port`` may be empty to discover a unique FT231X USB dongle. +If several match, set ``serial_number`` to the desired USB dongle serial or specify the port explicitly. +See :doc:`SerialPortDiscovery` for selection rules, timeout settings and limitations. + Initialization Example: .. code-block:: python @@ -309,6 +313,9 @@ To create such board you need to specify the following board ID and fields of Br - :code:`BoardIds.CYTON_DAISY_BOARD` - :code:`serial_port`, e.g. COM3, /dev/ttyUSB0, etc +The same automatic USB-port discovery and USB ``serial_number`` selection as Cyton are available. +See :doc:`SerialPortDiscovery`. + Initialization Example: .. code-block:: python diff --git a/docs/index.rst b/docs/index.rst index 5ae0197f2..be65d2651 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ It provides a **uniform data acquisition API for all supported boards**, it mean Examples GameEngines BrainFlowDev + SerialPortDiscovery AskHelp Partners License diff --git a/src/board_controller/build.cmake b/src/board_controller/build.cmake index f3fb0d790..a9adf9a06 100644 --- a/src/board_controller/build.cmake +++ b/src/board_controller/build.cmake @@ -28,6 +28,7 @@ SET (BOARD_CONTROLLER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/os_serial.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/os_serial_ioctl.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/serial.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/serial_port_info.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/libftdi_serial.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/socket_client_tcp.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/socket_client_udp.cpp @@ -39,6 +40,7 @@ SET (BOARD_CONTROLLER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/broadcast_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/galea.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/openbci_serial_board.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/openbci_serial_helpers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/openbci_wifi_shield_board.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/ganglion_wifi.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/board_controller/openbci/cyton_wifi.cpp @@ -162,6 +164,9 @@ target_include_directories ( target_compile_definitions(${BOARD_CONTROLLER_NAME} PRIVATE NOMINMAX BRAINFLOW_VERSION=${BRAINFLOW_VERSION}) +include (${CMAKE_CURRENT_SOURCE_DIR}/cmake/serial_port_info.cmake) +link_serial_port_info (${BOARD_CONTROLLER_NAME}) + if (BUILD_BLE) target_compile_definitions (${BOARD_CONTROLLER_NAME} PRIVATE BRAINFLOW_BUILD_BLE) if (ANDROID) diff --git a/src/board_controller/openbci/inc/openbci_serial_helpers.h b/src/board_controller/openbci/inc/openbci_serial_helpers.h new file mode 100644 index 000000000..5daa5a8ad --- /dev/null +++ b/src/board_controller/openbci/inc/openbci_serial_helpers.h @@ -0,0 +1,11 @@ +#pragma once + +#include "serial.h" +#include + +// Internal board policy; USB identities are deliberately not public board-description fields. +std::vector get_openbci_serial_candidates ( + int board_id, const std::vector &ports, const std::string &serial_number); + +bool wait_for_openbci_welcome ( + Serial &serial, std::chrono::milliseconds timeout, std::string &response); diff --git a/src/board_controller/openbci/openbci_serial_board.cpp b/src/board_controller/openbci/openbci_serial_board.cpp index c7e6556a4..beb302859 100644 --- a/src/board_controller/openbci/openbci_serial_board.cpp +++ b/src/board_controller/openbci/openbci_serial_board.cpp @@ -2,6 +2,7 @@ #include "openbci_sampling_tracker.h" #include "openbci_serial_board.h" +#include "openbci_serial_helpers.h" #include "serial.h" #ifdef _WIN32 #include "windows_registry.h" @@ -137,49 +138,24 @@ int OpenBCISerialBoard::set_port_settings () int set_latency_res = serial->set_custom_latency (1); safe_logger (spdlog::level::info, "set_latency_res is: {}", set_latency_res); #endif + // Clear stale input before requesting a fresh welcome, never after sending "v". + // Older optional libftdi versions do not implement flushing. + if (serial->flush_buffer () != SerialExitCodes::OK) + { + safe_logger (spdlog::level::warn, "Unable to flush serial buffer before board reset"); + } return send_to_board ("v"); } int OpenBCISerialBoard::status_check () { - unsigned char buf[1]; - int count = 0; - int max_empty_seq = 5; - int num_empty_attempts = 0; - std::string resp = ""; - - for (int i = 0; i < 500; i++) + std::string response; + int timeout = params.timeout > 0 ? params.timeout : 6; + if (wait_for_openbci_welcome (*serial, std::chrono::seconds (timeout), response)) { - int res = serial->read_from_serial_port (buf, 1); - if (res > 0) - { - resp += buf[0]; - num_empty_attempts = 0; - // board is ready if there are '$$$' - if (buf[0] == '$') - { - count++; - } - else - { - count = 0; - } - if (count == 3) - { - return (int)BrainFlowExitCodes::STATUS_OK; - } - } - else - { - num_empty_attempts++; - if (num_empty_attempts > max_empty_seq) - { - safe_logger (spdlog::level::err, "board doesnt send welcome characters! Msg: {}", - resp.c_str ()); - return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; - } - } + return (int)BrainFlowExitCodes::STATUS_OK; } + safe_logger (spdlog::level::err, "board doesnt send welcome characters! Msg: {}", response); return (int)BrainFlowExitCodes::BOARD_NOT_READY_ERROR; } @@ -190,13 +166,42 @@ int OpenBCISerialBoard::prepare_session () safe_logger (spdlog::level::info, "Session already prepared"); return (int)BrainFlowExitCodes::STATUS_OK; } - if (params.serial_port.empty ()) + if (params.timeout < 0 || params.timeout > 600) { - safe_logger (spdlog::level::err, "serial port is empty"); + safe_logger (spdlog::level::err, "timeout must be between 0 and 600 seconds"); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } + std::string port_name = params.serial_port; + if (port_name.empty ()) + { + std::vector ports; + int enumeration_result = Serial::get_port_info (ports); + if (enumeration_result != SerialExitCodes::OK) + { + safe_logger (spdlog::level::err, + "Unable to enumerate serial ports ({}); specify serial_port explicitly", + enumeration_result); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + auto candidates = get_openbci_serial_candidates (board_id, ports, params.serial_number); + if (candidates.size () != 1) + { + safe_logger (spdlog::level::err, + "Found {} matching Cyton USB ports; specify serial_port or a unique USB " + "serial_number", + candidates.size ()); + for (const auto &candidate : candidates) + { + safe_logger (spdlog::level::info, "candidate port: {}, USB serial_number: {}", + candidate.port, candidate.serial_number); + } + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + port_name = candidates[0].port; + safe_logger (spdlog::level::info, "discovered Cyton candidate port {}", port_name); + } #ifdef _WIN32 - LONG res = set_ftdi_latency_in_registry (1, params.serial_port); + LONG res = set_ftdi_latency_in_registry (1, port_name); if (res != ERROR_SUCCESS) { safe_logger (spdlog::level::warn, @@ -204,7 +209,7 @@ int OpenBCISerialBoard::prepare_session () "reconnection may be needed."); } #endif - serial = Serial::create (params.serial_port.c_str (), this); + serial = Serial::create (port_name.c_str (), this); int port_open = open_port (); if (port_open != (int)BrainFlowExitCodes::STATUS_OK) { @@ -232,6 +237,8 @@ int OpenBCISerialBoard::prepare_session () int send_res = send_to_board ("d"); if (send_res != (int)BrainFlowExitCodes::STATUS_OK) { + delete serial; + serial = NULL; return send_res; } // cyton sends response back, clean serial buffer and analyze response diff --git a/src/board_controller/openbci/openbci_serial_helpers.cpp b/src/board_controller/openbci/openbci_serial_helpers.cpp new file mode 100644 index 000000000..c643d53be --- /dev/null +++ b/src/board_controller/openbci/openbci_serial_helpers.cpp @@ -0,0 +1,69 @@ +#include +#include + +#include "brainflow_constants.h" +#include "openbci_serial_helpers.h" + +std::vector get_openbci_serial_candidates ( + int board_id, const std::vector &ports, const std::string &serial_number) +{ + std::vector candidates; + if (board_id != (int)BoardIds::CYTON_BOARD && board_id != (int)BoardIds::CYTON_DAISY_BOARD) + { + return candidates; + } + // Cyton's FT231X dongle. These are adapter IDs, not proof of a Cyton connection; + // the normal board handshake must still succeed. Do not add generic FT232 IDs. + const int cyton_vid = 0x0403; + const int cyton_pid = 0x6015; + for (const auto &port : ports) + { + if (!port.port.empty () && port.vid == cyton_vid && port.pid == cyton_pid && + (serial_number.empty () || port.serial_number == serial_number)) + { + candidates.push_back (port); + } + } + std::sort (candidates.begin (), candidates.end (), + [] (const SerialPortInfo &a, const SerialPortInfo &b) { return a.port < b.port; }); + candidates.erase ( + std::unique (candidates.begin (), candidates.end (), + [] (const SerialPortInfo &a, const SerialPortInfo &b) { return a.port == b.port; }), + candidates.end ()); + return candidates; +} + +bool wait_for_openbci_welcome ( + Serial &serial, std::chrono::milliseconds timeout, std::string &response) +{ + response.clear (); + const auto deadline = std::chrono::steady_clock::now () + timeout; + int count = 0; + while (std::chrono::steady_clock::now () < deadline) + { + unsigned char byte; + int result = serial.read_from_serial_port (&byte, 1); + if (result < 0 || std::chrono::steady_clock::now () >= deadline) + { + return false; + } + if (result == 1) + { + if (response.size () < 4096) + { + response += (char)byte; + } + count = (byte == '$') ? count + 1 : 0; + if (count == 3) + { + return true; + } + } + else + { + // Some drivers return empty reads immediately; do not spin or count them as time. + std::this_thread::sleep_for (std::chrono::milliseconds (1)); + } + } + return false; +} diff --git a/src/tests/serial/CMakeLists.txt b/src/tests/serial/CMakeLists.txt new file mode 100644 index 000000000..6f6bbded3 --- /dev/null +++ b/src/tests/serial/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required (VERSION 3.13) +project (serial_discovery_tests LANGUAGES CXX) +set (CMAKE_CXX_STANDARD 11) +set (CMAKE_CXX_STANDARD_REQUIRED ON) +get_filename_component (BRAINFLOW_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE) +find_package (Threads REQUIRED) +add_executable (serial_discovery_tests + serial_discovery_tests.cpp + ${BRAINFLOW_ROOT}/src/utils/serial_port_info.cpp + ${BRAINFLOW_ROOT}/src/board_controller/openbci/openbci_serial_helpers.cpp) +target_include_directories (serial_discovery_tests PRIVATE + ${BRAINFLOW_ROOT}/src/utils/inc + ${BRAINFLOW_ROOT}/src/board_controller/openbci/inc) +target_compile_definitions (serial_discovery_tests PRIVATE NOMINMAX) +target_link_libraries (serial_discovery_tests PRIVATE Threads::Threads) +include (${BRAINFLOW_ROOT}/cmake/serial_port_info.cmake) +link_serial_port_info (serial_discovery_tests) +if (MSVC) + target_compile_options (serial_discovery_tests PRIVATE /W4 /WX) +else () + target_compile_options (serial_discovery_tests PRIVATE -Wall -Wextra -Werror) +endif () +enable_testing () +add_test (NAME serial_discovery_tests COMMAND serial_discovery_tests) diff --git a/src/tests/serial/openbci_serial_pty.py b/src/tests/serial/openbci_serial_pty.py new file mode 100644 index 000000000..9a6afb187 --- /dev/null +++ b/src/tests/serial/openbci_serial_pty.py @@ -0,0 +1,111 @@ +"""POSIX integration regression against a freshly built BoardController (no third-party packages).""" + +import ctypes +import json +import os +import pty +import select +import sys +import threading +import time + + +def params(**overrides): + result = {key: "" for key in ( + "serial_port", "serial_number", "mac_address", "other_info", "ip_address", + "ip_address_aux", "ip_address_anc", "file", "file_aux", "file_anc")} + result.update(ip_port=0, ip_port_aux=0, ip_port_anc=0, ip_protocol=0, timeout=0, master_board=-100) + result.update(overrides) + return json.dumps(result).encode() + + +class Dongle: + def __init__(self, delay=0, silent=False): + self.master, self.slave = pty.openpty() + self.name = os.ttyname(self.slave) + self.delay = delay + self.silent = silent + self.commands = [] + self.stop = threading.Event() + self.thread = threading.Thread(target=self.run) + self.thread.start() + + def run(self): + while not self.stop.is_set(): + if not select.select([self.master], [], [], 0.05)[0]: + continue + for command in os.read(self.master, 1024): + self.commands.append(command) + if command == ord("v") and not self.silent: + if self.stop.wait(self.delay): + return + # Deliver a fragmented welcome, including a gap between '$' characters. + os.write(self.master, b"OpenBCI PTY $") + if self.stop.wait(0.03): + return + os.write(self.master, b"$$") + elif command == ord("d"): + os.write(self.master, b"defaults loaded\n") + + def close(self): + self.stop.set() + self.thread.join(timeout=2) + assert not self.thread.is_alive() + os.close(self.master) + os.close(self.slave) + + +def main(library_path): + library = ctypes.CDLL(os.path.abspath(library_path)) + for name in ("prepare_session", "release_session"): + function = getattr(library, name) + function.argtypes = [ctypes.c_int, ctypes.c_char_p] + function.restype = ctypes.c_int + library.is_prepared.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p] + library.is_prepared.restype = ctypes.c_int + + # This serial filter must never fall back to opening any installed adapter. + assert library.prepare_session(0, params(serial_number="__brainflow_missing_test_device__")) == 13 + for timeout in (-1, 601): + assert library.prepare_session(0, params(serial_port="/missing", timeout=timeout)) == 13 + assert library.prepare_session(0, params(serial_port="/brainflow_nonexistent_port")) == 2 + + devices = [Dongle(delay=0.2), Dongle(delay=0.1)] + configurations = [params(serial_port=device.name, serial_number="ignored_with_explicit_port") + for device in devices] + try: + for board_id, configuration in zip((0, 2), configurations): + assert library.prepare_session(board_id, configuration) == 0 + prepared = ctypes.c_int() + assert library.is_prepared(ctypes.byref(prepared), board_id, configuration) == 0 + assert prepared.value == 1 + for board_id, device, configuration in zip((0, 2), devices, configurations): + assert library.release_session(board_id, configuration) == 0 + assert device.commands[:2] == [ord("v"), ord("d")] + # Reopen proves release and the original session key still work. + assert library.prepare_session(board_id, configuration) == 0 + assert library.release_session(board_id, configuration) == 0 + finally: + library.release_all_sessions() + for device in devices: + device.close() + + device = Dongle(silent=True) + configuration = params(serial_port=device.name, timeout=1) + try: + start = time.monotonic() + assert library.prepare_session(0, configuration) == 7 + elapsed = time.monotonic() - start + assert 0.9 <= elapsed < 3, elapsed + # Failure must not leave a prepared session or an unusable port. + device.silent = False + assert library.prepare_session(0, configuration) == 0 + assert library.release_session(0, configuration) == 0 + finally: + library.release_all_sessions() + device.close() + print("OpenBCI PTY integration tests passed") + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/src/tests/serial/serial_discovery_tests.cpp b/src/tests/serial/serial_discovery_tests.cpp new file mode 100644 index 000000000..da4b9c4bf --- /dev/null +++ b/src/tests/serial/serial_discovery_tests.cpp @@ -0,0 +1,296 @@ +#include +#include +#include +#include +#include + +#include "brainflow_constants.h" +#include "openbci_serial_helpers.h" +#include "serial_port_info.h" + +#if defined(__linux__) && !defined(__ANDROID__) +#include +#include +#include +#include +#endif + +static void require (bool value, const char *message) +{ + if (!value) + { + throw std::runtime_error (message); + } +} + +static SerialPortInfo port ( + const std::string &name, const std::string &serial, int vid = 0x0403, int pid = 0x6015) +{ + SerialPortInfo result; + result.port = name; + result.serial_number = serial; + result.vid = vid; + result.pid = pid; + return result; +} + +static void test_candidates () +{ + const int cyton = (int)BoardIds::CYTON_BOARD; + auto a = port ("COM10", "A"); + auto b = port ("COM2", "B"); + auto unrelated = port ("COM3", "A", 0x0403, 0x6001); + require (get_openbci_serial_candidates (cyton, {}, "").empty (), "empty enumeration"); + require (get_openbci_serial_candidates (cyton, {unrelated}, "").empty (), "wrong PID"); + require (get_openbci_serial_candidates (cyton, {port ("X", "A", -1, -1)}, "").empty (), + "missing USB IDs"); + require (get_openbci_serial_candidates (cyton, {port ("X", "A", 1)}, "").empty (), "wrong VID"); + require ( + get_openbci_serial_candidates (cyton, {port ("", "A")}, "").empty (), "missing port name"); + require (get_openbci_serial_candidates (cyton, {a, unrelated}, "").size () == 1, + "unique candidate among unrelated adapters"); + auto ambiguous = get_openbci_serial_candidates (cyton, {b, a}, ""); + require (ambiguous.size () == 2, "two identical devices must remain ambiguous"); + auto reversed = get_openbci_serial_candidates (cyton, {a, b}, ""); + require (ambiguous[0].port == reversed[0].port && ambiguous[1].port == reversed[1].port, + "enumeration ordering must not change the candidates"); + auto selected = get_openbci_serial_candidates (cyton, {a, b, unrelated}, "B"); + require (selected.size () == 1 && selected[0].port == "COM2", "USB serial selection"); + require (get_openbci_serial_candidates (cyton, {a, b}, "missing").empty (), + "serial mismatch must not fall back"); + require (get_openbci_serial_candidates (cyton, {port ("COM1", "")}, "A").empty (), + "unknown serial must not match a serial filter"); + require (get_openbci_serial_candidates (cyton, {a, port ("COM2", "A")}, "A").size () == 2, + "duplicate USB serials must remain ambiguous"); + require (get_openbci_serial_candidates (cyton, {a, a}, "").size () == 1, + "duplicate OS entries must not create ambiguity"); + require ( + get_openbci_serial_candidates ((int)BoardIds::CYTON_DAISY_BOARD, {a}, "A").size () == 1, + "Daisy uses the same dongle"); + require (get_openbci_serial_candidates ((int)BoardIds::GANGLION_BOARD, {a}, "").empty (), + "no generic USB fallback for other boards"); +} + +static void test_windows_metadata () +{ + SerialPortInfo value; + require (serial_port_info::parse_windows_usb_instance ( + "USB\\VID_0403&PID_6015\\DM12345", true, value) && + value.vid == 0x0403 && value.pid == 0x6015 && value.serial_number == "DM12345", + "physical USB instance"); + require (serial_port_info::parse_windows_usb_instance ( + "usb\\vid_0403&pid_6015\\5&LOCATION&0&1", false, value) && + value.serial_number.empty (), + "do not expose Windows location IDs as USB serials"); + for (const char *invalid : {"", "USB\\VID_", "USB\\VID_0403&PID_6015\\", + "USB\\VID_0403&PID_6015&MI_00\\6&CHILD", "FTDIBUS\\VID_0403+PID_6015+A\\0000", + "USB\\VID_zzzz&PID_6015\\A", "USB\\VID_0403&PID_601x\\A"}) + { + require (!serial_port_info::parse_windows_usb_instance (invalid, true, value), + "malformed IDs and non-physical USB children must be rejected"); + } + require (serial_port_info::parse_usb_id ("aB09") == 0xab09, "mixed case hex"); + require (serial_port_info::parse_usb_id ("0403junk") == -1, "reject trailing data"); + require (serial_port_info::parse_usb_id ("+403") == -1, "reject signed hex"); +} + +class FakeSerial : public Serial +{ +public: + int reads = 0; + int empty_reads = 0; + bool error = false; + bool noise = false; + int read_delay_ms = 0; + std::string data; + + int read_from_serial_port (void *bytes, int) override + { + reads++; + if (read_delay_ms) + { + std::this_thread::sleep_for (std::chrono::milliseconds (read_delay_ms)); + } + if (error) + { + return -1; + } + if (reads <= empty_reads) + { + return 0; + } + if (noise) + { + *(unsigned char *)bytes = 'x'; + return 1; + } + if (data.empty ()) + { + return 0; + } + *(unsigned char *)bytes = (unsigned char)data[0]; + data.erase (0, 1); + return 1; + } + int open_serial_port () override + { + throw std::runtime_error ("unexpected open"); + } + bool is_port_open () override + { + return true; + } + int set_serial_port_settings (int, bool) override + { + return 0; + } + int set_custom_baudrate (int) override + { + return 0; + } + int set_custom_latency (int) override + { + return 0; + } + int flush_buffer () override + { + throw std::runtime_error ("flushed after reset"); + } + int send_to_serial_port (const void *, int) override + { + throw std::runtime_error ("unexpected write"); + } + int close_serial_port () override + { + return 0; + } + const char *get_port_name () override + { + return "fake"; + } +}; + +static void test_welcome () +{ + std::string response; + FakeSerial delayed; + delayed.empty_reads = 10; + delayed.data = "OpenBCI$$x$$$"; + require (wait_for_openbci_welcome (delayed, std::chrono::seconds (2), response), + "welcome after more than six empty reads"); + require (response == "OpenBCI$$x$$$", "fragmented terminator and reset of dollar count"); + + FakeSerial silent; + auto start = std::chrono::steady_clock::now (); + require (!wait_for_openbci_welcome (silent, std::chrono::milliseconds (40), response), + "silent device must time out"); + auto elapsed = std::chrono::steady_clock::now () - start; + require (elapsed >= std::chrono::milliseconds (40) && elapsed < std::chrono::seconds (2), + "elapsed-time deadline"); + require (silent.reads < 100, "empty reads must back off"); + + FakeSerial noisy; + noisy.noise = true; + require (!wait_for_openbci_welcome (noisy, std::chrono::milliseconds (40), response), + "continuous noise must not extend the deadline"); + require (response.size () <= 4096, "bounded diagnostic buffer"); + + FakeSerial long_welcome; + long_welcome.data = std::string (5000, 'x') + "$$$"; + require (wait_for_openbci_welcome (long_welcome, std::chrono::seconds (2), response), + "welcome is not limited by old 500-read cap or diagnostic buffer"); + + FakeSerial disconnected; + disconnected.error = true; + require (!wait_for_openbci_welcome (disconnected, std::chrono::seconds (2), response) && + disconnected.reads == 1, + "read error must stop immediately"); + + FakeSerial late; + late.read_delay_ms = 20; + late.data = "$$$"; + require (!wait_for_openbci_welcome (late, std::chrono::milliseconds (10), response), + "read completing after deadline must not succeed"); +} + +#if defined(__linux__) && !defined(__ANDROID__) +static void test_linux_metadata () +{ + char temp[] = "/tmp/brainflow-serial-XXXXXX"; + char *created = mkdtemp (temp); + require (created != NULL, "create fixture directory"); + const std::string root = created; + const std::vector directories = {"/sys", "/dev", "/usb", "/usb/interface", + "/usb/interface/tty", "/sys/ttyUSB0", "/sys/ttyUSB1", "/sys/ttyS0", "/sys/ttyUSB2"}; + for (const auto &dir : directories) + { + require (mkdir ((root + dir).c_str (), 0700) == 0, "create fixture hierarchy"); + } + std::ofstream (root + "/usb/idVendor") << "0403\n"; + std::ofstream (root + "/usb/idProduct") << "6015\n"; + std::ofstream (root + "/usb/serial") << "DONGLE_A\n"; + for (const auto &name : {"ttyUSB0", "ttyUSB1"}) + { + require (symlink ((root + "/usb/interface/tty").c_str (), + (root + "/sys/" + name + "/device").c_str ()) == 0, + "create sysfs link"); + } + // A FIFO would block if enumeration opened the serial node; metadata lookup must not do so. + require (mkfifo ((root + "/dev/ttyUSB0").c_str (), 0600) == 0, "create unopened device node"); + std::ofstream (root + "/dev/ttyS0"); + std::ofstream (root + "/dev/ttyUSB2"); + require (symlink ((root + "/missing").c_str (), (root + "/sys/ttyUSB2/device").c_str ()) == 0, + "create disconnected device link"); + std::vector ports; + require (serial_port_info::enumerate_linux (ports, root + "/sys", root + "/dev") == 0 && + ports.size () == 1 && ports[0].port == root + "/dev/ttyUSB0" && + ports[0].vid == 0x0403 && ports[0].pid == 0x6015 && + ports[0].serial_number == "DONGLE_A", + "sysfs USB ancestor, missing device nodes, non-USB and hot-unplug handling"); + unlink ((root + "/usb/serial").c_str ()); + require (serial_port_info::enumerate_linux (ports, root + "/sys", root + "/dev") == 0 && + ports.size () == 1 && ports[0].serial_number.empty (), + "optional USB serial"); + std::ofstream (root + "/usb/idVendor") << "bad!\n"; + require (serial_port_info::enumerate_linux (ports, root + "/sys", root + "/dev") == 0 && + ports.empty (), + "malformed sysfs IDs"); + require (serial_port_info::enumerate_linux (ports, root + "/missing", root + "/dev") == + SerialExitCodes::ENUMERATION_ERROR, + "enumeration failure differs from zero ports"); + for (const auto &file : {"/usb/idVendor", "/usb/idProduct", "/dev/ttyUSB0", "/dev/ttyS0", + "/dev/ttyUSB2", "/sys/ttyUSB0/device", "/sys/ttyUSB1/device", "/sys/ttyUSB2/device"}) + { + unlink ((root + file).c_str ()); + } + for (auto it = directories.rbegin (); it != directories.rend (); ++it) + { + rmdir ((root + *it).c_str ()); + } + rmdir (root.c_str ()); +} +#endif + +int main () +{ + try + { + test_candidates (); + test_windows_metadata (); + test_welcome (); +#if defined(__linux__) && !defined(__ANDROID__) + test_linux_metadata (); +#endif + std::vector ports; + require ( + Serial::get_port_info (ports) == SerialExitCodes::OK, "native enumeration smoke test"); + std::cout << "Serial discovery and welcome tests passed (" << ports.size () + << " native USB serial ports)." << std::endl; + return 0; + } + catch (const std::exception &error) + { + std::cerr << error.what () << std::endl; + return 1; + } +} diff --git a/src/utils/inc/serial.h b/src/utils/inc/serial.h index 76d383d24..77f3be102 100644 --- a/src/utils/inc/serial.h +++ b/src/utils/inc/serial.h @@ -1,5 +1,16 @@ #pragma once +#include +#include + +// Metadata only: enumeration must never open a port or send device commands. +struct SerialPortInfo +{ + std::string port; + int vid = -1; + int pid = -1; + std::string serial_number; +}; enum SerialExitCodes : int { @@ -11,7 +22,8 @@ enum SerialExitCodes : int CLOSE_ERROR = -5, NO_SYSTEM_HEADERS_FOUND_ERROR = -6, PORT_NAME_ERROR = -7, - NO_LIBFTDI_ERROR = -8 + NO_LIBFTDI_ERROR = -8, + ENUMERATION_ERROR = -9 }; class Board; @@ -20,6 +32,9 @@ class Serial { public: static Serial *create (const char *port_name, Board *board = nullptr); + // USB serial ports with OS device nodes. Does not enumerate libftdi URLs. + // An empty successful result differs from an unsupported/failed enumeration. + static int get_port_info (std::vector &ports); virtual ~Serial () { diff --git a/src/utils/inc/serial_port_info.h b/src/utils/inc/serial_port_info.h new file mode 100644 index 000000000..17911277f --- /dev/null +++ b/src/utils/inc/serial_port_info.h @@ -0,0 +1,16 @@ +#pragma once + +#include "serial.h" + +// Internal helpers shared by native enumeration and hardware-independent tests. +namespace serial_port_info +{ + int parse_usb_id (const std::string &value); + bool parse_windows_usb_instance ( + const std::string &instance, bool unique_id, SerialPortInfo &port); + +#if defined(__linux__) && !defined(__ANDROID__) + int enumerate_linux (std::vector &ports, const std::string &sysfs_root, + const std::string &dev_root); +#endif +} diff --git a/src/utils/serial_port_info.cpp b/src/utils/serial_port_info.cpp new file mode 100644 index 000000000..7d8d360ac --- /dev/null +++ b/src/utils/serial_port_info.cpp @@ -0,0 +1,328 @@ +#include +#include +#include + +#include "serial_port_info.h" + +#ifdef _WIN32 +#include +#include +#include +#elif defined(__APPLE__) +#include +#if TARGET_OS_OSX +#include +#include +#include +#endif +#elif defined(__linux__) && !defined(__ANDROID__) +#include +#include +#include +#include +#include +#endif + +int serial_port_info::parse_usb_id (const std::string &value) +{ + if (value.size () != 4) + { + return -1; + } + int result = 0; + for (char c : value) + { + c = (char)std::tolower ((unsigned char)c); + int digit = (c >= '0' && c <= '9') ? c - '0' : ((c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1); + if (digit < 0) + { + return -1; + } + result = result * 16 + digit; + } + return result; +} + +bool serial_port_info::parse_windows_usb_instance ( + const std::string &instance, bool unique_id, SerialPortInfo &port) +{ + std::string id = instance; + std::transform (id.begin (), id.end (), id.begin (), + [] (unsigned char c) { return (char)std::toupper (c); }); + // Walk past composite interfaces and FTDIBUS children to the physical USB device. + if (id.size () < 23 || id.compare (0, 8, "USB\\VID_") != 0 || id.compare (12, 5, "&PID_") != 0) + { + return false; + } + size_t separator = id.find ('\\', 8); + if (separator != 21 || separator + 1 == id.size ()) + { + return false; + } + int vid = parse_usb_id (id.substr (8, 4)); + int pid = parse_usb_id (id.substr (17, 4)); + if (vid < 0 || pid < 0) + { + return false; + } + port.vid = vid; + port.pid = pid; + // A location-generated instance suffix is not a USB serial number. + port.serial_number = unique_id ? instance.substr (separator + 1) : ""; + return true; +} + +#ifdef _WIN32 +static int enumerate_native (std::vector &ports) +{ + // GUID_DEVCLASS_PORTS, avoiding a dependency on uuid.lib. + const GUID ports_class = { + 0x4d36e978, 0xe325, 0x11ce, {0xbf, 0xc1, 0x08, 0x00, 0x2b, 0xe1, 0x03, 0x18}}; + HDEVINFO devices = SetupDiGetClassDevsA (&ports_class, NULL, NULL, DIGCF_PRESENT); + if (devices == INVALID_HANDLE_VALUE) + { + return SerialExitCodes::ENUMERATION_ERROR; + } + int result = SerialExitCodes::OK; + for (DWORD index = 0;; index++) + { + SP_DEVINFO_DATA device = {}; + device.cbSize = sizeof (device); + if (!SetupDiEnumDeviceInfo (devices, index, &device)) + { + if (GetLastError () != ERROR_NO_MORE_ITEMS) + { + result = SerialExitCodes::ENUMERATION_ERROR; + } + break; + } + HKEY key = SetupDiOpenDevRegKey ( + devices, &device, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_QUERY_VALUE); + if (key == INVALID_HANDLE_VALUE) + { + continue; + } + char name[256] = {}; + DWORD size = sizeof (name) - 1; + DWORD type = 0; + LONG status = RegQueryValueExA (key, "PortName", NULL, &type, (LPBYTE)name, &size); + RegCloseKey (key); + if (status != ERROR_SUCCESS || type != REG_SZ) + { + continue; + } + SerialPortInfo port; + port.port = name; + if (port.port.size () < 4 || port.port.compare (0, 3, "COM") != 0 || + port.port.find_first_not_of ("0123456789", 3) != std::string::npos) + { + continue; + } + DEVINST node = device.DevInst; + for (int depth = 0; depth < 32; depth++) + { + char instance[MAX_DEVICE_ID_LEN] = {}; + if (CM_Get_Device_IDA (node, instance, MAX_DEVICE_ID_LEN, 0) == CR_SUCCESS) + { + ULONG capabilities = 0; + ULONG bytes = sizeof (capabilities); + CM_Get_DevNode_Registry_PropertyA ( + node, CM_DRP_CAPABILITIES, NULL, &capabilities, &bytes, 0); + if (serial_port_info::parse_windows_usb_instance ( + instance, (capabilities & CM_DEVCAP_UNIQUEID) != 0, port)) + { + ports.push_back (port); + break; + } + } + DEVINST parent; + if (CM_Get_Parent (&parent, node, 0) != CR_SUCCESS) + { + break; + } + node = parent; + } + } + SetupDiDestroyDeviceInfoList (devices); + return result; +} +#elif defined(__APPLE__) && TARGET_OS_OSX +static std::string string_property (io_registry_entry_t entry, CFStringRef key) +{ + std::string result; + CFTypeRef value = IORegistryEntryCreateCFProperty (entry, key, kCFAllocatorDefault, 0); + if (value) + { + char buffer[1024]; + if (CFGetTypeID (value) == CFStringGetTypeID () && + CFStringGetCString ((CFStringRef)value, buffer, sizeof (buffer), kCFStringEncodingUTF8)) + { + result = buffer; + } + CFRelease (value); + } + return result; +} + +static int number_property (io_registry_entry_t entry, CFStringRef key) +{ + int result = -1; + CFTypeRef value = IORegistryEntryCreateCFProperty (entry, key, kCFAllocatorDefault, 0); + if (value) + { + if (CFGetTypeID (value) == CFNumberGetTypeID ()) + { + CFNumberGetValue ((CFNumberRef)value, kCFNumberIntType, &result); + } + CFRelease (value); + } + return result; +} + +static int enumerate_native (std::vector &ports) +{ + CFMutableDictionaryRef matching = IOServiceMatching (kIOSerialBSDServiceValue); + if (!matching) + { + return SerialExitCodes::ENUMERATION_ERROR; + } + io_iterator_t iterator = IO_OBJECT_NULL; + // Zero is the default IOKit port, also on older supported macOS SDKs. + if (IOServiceGetMatchingServices (0, matching, &iterator) != KERN_SUCCESS) + { + return SerialExitCodes::ENUMERATION_ERROR; + } + io_registry_entry_t node; + while ((node = IOIteratorNext (iterator)) != IO_OBJECT_NULL) + { + SerialPortInfo port; + // Only the callout path: /dev/tty.* would duplicate the same physical device. + port.port = string_property (node, CFSTR (kIOCalloutDeviceKey)); + for (int depth = 0; depth < 32; depth++) + { + if (IOObjectConformsTo (node, "IOUSBHostDevice") || + IOObjectConformsTo (node, "IOUSBDevice")) + { + port.vid = number_property (node, CFSTR ("idVendor")); + port.pid = number_property (node, CFSTR ("idProduct")); + port.serial_number = string_property (node, CFSTR ("USB Serial Number")); + if (!port.port.empty () && port.vid >= 0 && port.pid >= 0) + { + ports.push_back (port); + } + break; + } + io_registry_entry_t parent; + if (IORegistryEntryGetParentEntry (node, kIOServicePlane, &parent) != KERN_SUCCESS) + { + break; + } + IOObjectRelease (node); + node = parent; + } + IOObjectRelease (node); + } + IOObjectRelease (iterator); + return SerialExitCodes::OK; +} +#elif defined(__linux__) && !defined(__ANDROID__) +static std::string read_attribute (const std::string &path) +{ + std::ifstream input (path); + std::string value; + std::getline (input, value); + return value; +} + +int serial_port_info::enumerate_linux ( + std::vector &ports, const std::string &sysfs_root, const std::string &dev_root) +{ + ports.clear (); + DIR *directory = opendir (sysfs_root.c_str ()); + if (!directory) + { + return SerialExitCodes::ENUMERATION_ERROR; + } + int result = SerialExitCodes::OK; + while (true) + { + errno = 0; + struct dirent *entry = readdir (directory); + if (!entry) + { + result = errno == 0 ? SerialExitCodes::OK : SerialExitCodes::ENUMERATION_ERROR; + break; + } + if (entry->d_name[0] == '.') + { + continue; + } + SerialPortInfo port; + port.port = dev_root + "/" + entry->d_name; + struct stat info; + if (stat (port.port.c_str (), &info) != 0) + { + continue; + } + std::string device = sysfs_root + "/" + entry->d_name + "/device"; + char *resolved = realpath (device.c_str (), NULL); + if (!resolved) + { + continue; + } + device = resolved; + free (resolved); + while (!device.empty ()) + { + std::string vid = read_attribute (device + "/idVendor"); + std::string pid = read_attribute (device + "/idProduct"); + if (!vid.empty () || !pid.empty ()) + { + port.vid = parse_usb_id (vid); + port.pid = parse_usb_id (pid); + port.serial_number = read_attribute (device + "/serial"); + if (port.vid >= 0 && port.pid >= 0) + { + ports.push_back (port); + } + break; + } + device = device.substr (0, device.find_last_of ('/')); + } + } + closedir (directory); + if (result != SerialExitCodes::OK) + { + ports.clear (); + } + return result; +} + +static int enumerate_native (std::vector &ports) +{ + return serial_port_info::enumerate_linux (ports, "/sys/class/tty", "/dev"); +} +#else +static int enumerate_native (std::vector &ports) +{ + return SerialExitCodes::NO_SYSTEM_HEADERS_FOUND_ERROR; +} +#endif + +int Serial::get_port_info (std::vector &ports) +{ + ports.clear (); + int result = enumerate_native (ports); + if (result != SerialExitCodes::OK) + { + ports.clear (); // Never select from an incomplete enumeration after an error. + return result; + } + std::sort (ports.begin (), ports.end (), + [] (const SerialPortInfo &a, const SerialPortInfo &b) { return a.port < b.port; }); + ports.erase ( + std::unique (ports.begin (), ports.end (), + [] (const SerialPortInfo &a, const SerialPortInfo &b) { return a.port == b.port; }), + ports.end ()); + return SerialExitCodes::OK; +} From b8d2daff66aaa5eee710e18d5c2f9971fb069f80 Mon Sep 17 00:00:00 2001 From: Andrey Parfenov Date: Tue, 27 Oct 2026 08:49:35 +0100 Subject: [PATCH 2/2] Keep Windows platform headers in dependency order --- src/utils/serial_port_info.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/serial_port_info.cpp b/src/utils/serial_port_info.cpp index 7d8d360ac..a7c430e66 100644 --- a/src/utils/serial_port_info.cpp +++ b/src/utils/serial_port_info.cpp @@ -5,9 +5,10 @@ #include "serial_port_info.h" #ifdef _WIN32 +#include + #include #include -#include #elif defined(__APPLE__) #include #if TARGET_OS_OSX @@ -303,7 +304,7 @@ static int enumerate_native (std::vector &ports) return serial_port_info::enumerate_linux (ports, "/sys/class/tty", "/dev"); } #else -static int enumerate_native (std::vector &ports) +static int enumerate_native (std::vector &) { return SerialExitCodes::NO_SYSTEM_HEADERS_FOUND_ERROR; }