diff --git a/README.md b/README.md index cd2104a..9c22f7b 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,19 @@ framework = arduino board = ardulinux ``` -Hardware support is gated on **libgpiod**: if `pkg-config` finds it, real GPIO/I2C are compiled in — this also links **libi2c**, so install the two together. If libgpiod is absent, the build uses fully simulated GPIO/I2C and needs no hardware libraries. +Hardware support is gated on **libgpiod**: if `pkg-config` finds it, real GPIO/I2C are compiled in; this also links **libi2c**, so install the two together. If libgpiod is absent, the build uses fully simulated GPIO/I2C and needs no hardware libraries. ## Building standalone (CMake) Requires GCC or Clang (C++14), CMake 3.17+, and pkg-config. Hardware GPIO/I2C are enabled when libgpiod is detected; libgpiod also requires libi2c, so install both together (or neither, for a simulated build). -ArduinoCore-API and WiFi are git submodules — clone with them, or initialise them after cloning: +ArduinoCore-API and WiFi are git submodules. Clone with them, or initialise them after cloning: ```sh git clone --recurse-submodules https://github.com/l5yth/ardulinux.git # already cloned? → git submodule update --init --recursive ``` -Install the build dependencies — on Debian/Ubuntu: +Install the build dependencies. On Debian/Ubuntu: ```sh sudo apt-get install build-essential cmake libgpiod-dev libi2c-dev pkg-config ``` @@ -107,7 +107,7 @@ The VFS root defaults to `$XDG_DATA_HOME//default` (i.e. `~/.local/share/ar ### Customizing program identity -The platform reads four optional weak symbols. Define any of them as plain (non-weak) definitions in an application source file to override the defaults — no header required: +The platform reads four optional weak symbols. Define any of them as plain (non-weak) definitions in an application source file to override the defaults. No header required: ```cpp const char *ardulinuxAppName = "meshcored"; // startup msg, VFS dir, libgpiod label (default "ardulinux") diff --git a/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp b/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp index d732034..45db60e 100644 --- a/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp +++ b/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp @@ -182,7 +182,18 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const char *linuxPinNam struct gpiod_line_config *line_cfg; struct gpiod_request_config *req_cfg = NULL; struct gpiod_line_request *line = NULL; - offset = gpiod_chip_get_line_offset_from_name(chip, linuxPinName); + // Returns -1 (ENOENT) for an unknown name; assigning that to the unsigned + // member would request offset 4294967295 instead of reporting the typo. + int named_offset = gpiod_chip_get_line_offset_from_name(chip, linuxPinName); + if (named_offset < 0) { + gpiod_chip_close(chip); + chip = NULL; + char msg[128]; + snprintf(msg, sizeof(msg), "Error, no GPIO line named '%s' on %s", + linuxPinName ? linuxPinName : "?", chipLabel ? chipLabel : "?"); + throw std::invalid_argument(msg); + } + offset = (unsigned int) named_offset; settings = gpiod_line_settings_new(); gpiod_line_settings_set_direction(settings, GPIOD_LINE_REQUEST_DIRECTION_AS_IS); line_cfg = gpiod_line_config_new(); @@ -215,6 +226,8 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const char *linuxPinNam consumer, GPIOD_LINE_REQUEST_DIRECTION_AS_IS, 0}; auto result = gpiod_line_request(line, &request, 0); if(result != 0) { + gpiod_chip_close(chip); + chip = NULL; throw std::invalid_argument("Error, cannot open GPIO chip"); } return line; @@ -229,12 +242,26 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const int linuxPinNum) if (!chip) throw std::invalid_argument("GPIO chip not found"); + // Guard before either library path: a negative offset (a config parser's + // "unset" sentinel, say) is unsigned on both sides -- v2's `offset` member + // and v1's gpiod_chip_get_line() -- so it would wrap to 4294967295 and be + // requested as if it were a real line. Deliberately outside the #if: the + // check needs no version-specific API. + if (linuxPinNum < 0) { + gpiod_chip_close(chip); + chip = NULL; + char msg[128]; + snprintf(msg, sizeof(msg), "Error, invalid GPIO line offset %d on %s", + linuxPinNum, chipLabel ? chipLabel : "?"); + throw std::invalid_argument(msg); + } + #if GPIOD_V == 2 struct gpiod_line_settings *settings; struct gpiod_line_config *line_cfg; struct gpiod_request_config *req_cfg = NULL; struct gpiod_line_request *line = NULL; - offset = linuxPinNum; + offset = (unsigned int) linuxPinNum; settings = gpiod_line_settings_new(); gpiod_line_settings_set_direction(settings, GPIOD_LINE_REQUEST_DIRECTION_AS_IS); line_cfg = gpiod_line_config_new(); @@ -262,12 +289,16 @@ gpiod_line *LinuxGPIOPin::getLine(const char *chipLabel, const int linuxPinNum) } return line; #else - auto line = gpiod_chip_get_line(chip, linuxPinNum); + // The negative guard above makes this conversion safe; make it explicit so + // the intent is not mistaken for the sign bug that guard exists to prevent. + auto line = gpiod_chip_get_line(chip, (unsigned int) linuxPinNum); struct gpiod_line_request_config request = { consumer, GPIOD_LINE_REQUEST_DIRECTION_AS_IS, 0}; auto result = gpiod_line_request(line, &request, 0); if(result != 0) { + gpiod_chip_close(chip); + chip = NULL; throw std::invalid_argument("Error, cannot open GPIO chip"); } return line; @@ -296,10 +327,32 @@ LinuxGPIOPin::~LinuxGPIOPin() { gpiod_chip_close(chip); } +/** + * Report a libgpiod failure on this line as an exception. + * + * assert() is not usable here: it is compiled out under NDEBUG, which is what + * release builds define, so a runtime gpiod error would go unreported. + */ +void LinuxGPIOPin::throwLineError(const char *op) const { + char msg[160]; +#if GPIOD_V == 2 + snprintf(msg, sizeof(msg), "Error, cannot %s GPIO line %u ('%s', pin %u): %s", + op, offset, getName(), (unsigned) getPinNum(), strerror(errno)); +#else + snprintf(msg, sizeof(msg), "Error, cannot %s GPIO '%s' (pin %u): %s", + op, getName(), (unsigned) getPinNum(), strerror(errno)); +#endif + log(SysGPIO, LogError, "%s", msg); + throw std::runtime_error(msg); +} + /// Read the low level hardware for this pin PinStatus LinuxGPIOPin::readPinHardware() { int res = gpiod_line_get_value(line); - assert(res == 0 || res == 1); // FIXME throw instead + // gpiod reports failure as GPIOD_LINE_VALUE_ERROR (-1). Returning it would + // cache -1 as the pin state and fire a phantom ISR from refreshState(). + if (res != 0 && res != 1) + throwLineError("read"); // log(SysGPIO, LogDebug, "readPinHardware(%s, %d)", getName(), res); return (PinStatus) res; @@ -309,13 +362,26 @@ void LinuxGPIOPin::writePin(PinStatus s) { // some libraries have been observed failing to set the pin mode to output. if (GPIOPin::getPinMode() != OUTPUT) setPinMode(OUTPUT); - GPIOPin::writePin(s); // update status + // Drive the hardware before caching. GPIOPin::writePin() records `s` as the + // pin's state, and once the mode is OUTPUT refreshState() stops re-reading + // the hardware, so a value cached for a write that never landed would be + // returned by digitalRead() forever. int res = gpiod_line_set_value(line, s); - assert(res == 0); + if (res != 0) + throwLineError("write"); + + GPIOPin::writePin(s); // update status } void LinuxGPIOPin::setPinMode(PinMode m) { +#if GPIOD_V == 2 + // Cache the mode up front: the output-value seed below reads readPin(), which + // must return the cached level rather than touching the hardware. If the + // reconfigure then fails, the cache is rolled back to `previous` -- leaving it + // moved would gate refreshState() on a direction the line does not have. + const PinMode previous = GPIOPin::getPinMode(); +#endif GPIOPin::setPinMode(m); #if GPIOD_V == 1 // The gpiod call below does not play well with an already claimed GPIO @@ -357,11 +423,17 @@ void LinuxGPIOPin::setPinMode(PinMode m) { } line_cfg = gpiod_line_config_new(); ret = gpiod_line_config_add_line_settings(line_cfg, &offset, 1, settings); - if (ret != 0) - log(SysGPIO, LogError, "gpiod_line_config_add_line_settings failed: %d", ret); + int add_ret = ret; + if (add_ret != 0) + log(SysGPIO, LogError, "gpiod_line_config_add_line_settings failed: %d", add_ret); ret = gpiod_line_request_reconfigure_lines(line, line_cfg); if (ret != 0) log(SysGPIO, LogError, "gpiod_line_request_reconfigure_lines failed: %d", ret); + // Either failure means the line kept its old direction, so the cache must + // too. add_line_settings is checked in its own right rather than trusting + // the reconfigure to fail on an empty config: the two are independent. + if (add_ret != 0 || ret != 0) + GPIOPin::setPinMode(previous); gpiod_line_config_free(line_cfg); gpiod_line_settings_free(settings); diff --git a/cores/ardulinux/linux/gpio/LinuxGPIOPin.h b/cores/ardulinux/linux/gpio/LinuxGPIOPin.h index a336697..52acf7b 100644 --- a/cores/ardulinux/linux/gpio/LinuxGPIOPin.h +++ b/cores/ardulinux/linux/gpio/LinuxGPIOPin.h @@ -59,7 +59,15 @@ */ class LinuxGPIOPin : public GPIOPin { gpiod_line *line; ///< Acquired GPIO line handle (type aliased for v1/v2) - gpiod_chip *chip; ///< GPIO chip handle (kept open for reconfiguration) + /** + * GPIO chip handle. + * + * Under gpiod v1 the chip is held open for the lifetime of the pin and + * closed by the destructor. Under gpiod v2 a line request outlives the + * chip it came from, so getLine() closes the chip as soon as the request + * succeeds and resets this to NULL; the destructor's close is then a no-op. + */ + gpiod_chip *chip; public: @@ -73,6 +81,11 @@ class LinuxGPIOPin : public GPIOPin { * @param linuxPinName Name of the GPIO line within the chip. * @param ardulinuxPinName Human-readable name for log messages (defaults to * linuxPinName if NULL). + * @throws std::invalid_argument if the chip is not found, the chip has no + * line by that name, or the line cannot be acquired. Acquisition is + * deliberately a construction-time failure: a pin that cannot be + * claimed is a configuration error, and the caller should learn that + * when it binds the pin rather than on first use. */ LinuxGPIOPin(pin_size_t n, const char *chipLabel, const char *linuxPinName, const char *ardulinuxPinName = NULL); @@ -83,6 +96,9 @@ class LinuxGPIOPin : public GPIOPin { * @param chipLabel Label of the gpiochip device. * @param linuxPinNum Zero-based offset of the GPIO line within the chip. * @param ardulinuxPinName Human-readable name for log messages. + * @throws std::invalid_argument if the chip is not found, the offset is + * negative, or the line cannot be acquired. See the by-name + * constructor for why this fails at construction time. */ LinuxGPIOPin(pin_size_t n, const char *chipLabel, const int linuxPinNum, const char *ardulinuxPinName); @@ -90,7 +106,15 @@ class LinuxGPIOPin : public GPIOPin { ~LinuxGPIOPin(); protected: - /** Read the current hardware pin level via gpiod_line_get_value(). */ + /** + * Read the current hardware pin level via gpiod_line_get_value(). + * + * @return LOW or HIGH. + * @throws std::runtime_error if libgpiod reports an error. The value is + * never passed through: gpiod signals failure with + * GPIOD_LINE_VALUE_ERROR (-1), which is not a valid PinStatus and + * would otherwise be cached as pin state and fire a spurious ISR. + */ virtual PinStatus readPinHardware(); /** @@ -98,6 +122,15 @@ class LinuxGPIOPin : public GPIOPin { * * Some libraries omit the pinMode(OUTPUT) call; this method silently * promotes the pin to output to avoid a silent no-op. + * + * The hardware is driven before the new level is cached, so a rejected + * write leaves the cached state untouched. That ordering matters: once the + * mode is OUTPUT, refreshState() stops re-reading the hardware, so a value + * cached for a write that never landed would be returned by digitalRead() + * for the rest of the process's life. + * + * @param s Logic level to drive. + * @throws std::runtime_error if libgpiod rejects the write. */ virtual void writePin(PinStatus s); @@ -106,10 +139,26 @@ class LinuxGPIOPin : public GPIOPin { * * Uses gpiod_line_release + gpiod_line_request_* (v1) or * gpiod_line_request_reconfigure_lines (v2). + * + * A failed reconfiguration is logged at LogError and does not throw: this is + * reached from writePin()'s auto-promotion path, where throwing would turn a + * recoverable reconfiguration into a lost write. The cached mode is rolled + * back instead, because the line kept its old direction and `mode` is what + * gates refreshState() -- a stale OUTPUT would stop all hardware reads and + * freeze digitalRead() at its last cached level. + * + * @param m Direction and bias to apply. */ virtual void setPinMode(PinMode m); - unsigned int offset; ///< Line offset within the chip (used by gpiod v2) + /** + * Line offset within the chip. + * + * Assigned by the gpiod v2 paths in getLine(); the v1 paths address the + * line through its own handle and never read this. Initialised anyway so + * the member is never indeterminate, since it is declared unconditionally. + */ + unsigned int offset = 0; private: /** @@ -117,7 +166,9 @@ class LinuxGPIOPin : public GPIOPin { * * @param chipLabel gpiochip label or device name. * @param linuxPinNum Line offset within the chip. - * @return Acquired line handle; throws std::invalid_argument on failure. + * @return Acquired line handle. + * @throws std::invalid_argument if the chip is not found, the offset is + * negative, or the line cannot be acquired. */ gpiod_line *getLine(const char *chipLabel, const int linuxPinNum); @@ -126,10 +177,24 @@ class LinuxGPIOPin : public GPIOPin { * * @param chipLabel gpiochip label or device name. * @param linuxPinName Line name as reported by the kernel. - * @return Acquired line handle; throws std::invalid_argument on failure. + * @return Acquired line handle. + * @throws std::invalid_argument if the chip is not found, the chip has no + * line by that name, or the line cannot be acquired. */ gpiod_line *getLine(const char *chipLabel, const char *linuxPinName); + /** + * Throw a std::runtime_error identifying this pin and the failed operation. + * + * Shared by readPinHardware() and writePin() so both report a libgpiod + * failure in the same form, including errno, the line offset (gpiod v2), + * the pin name and the Arduino pin number. + * + * @param op Verb naming the failed operation, e.g. "read" or "write". + * @throws std::runtime_error always; the function never returns. + */ + [[noreturn]] void throwLineError(const char *op) const; + /** @defgroup gpiod_v2_compat gpiod v2 compatibility shims * * gpiod v2 replaced the gpiod_line / gpiod_line_request split with a diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 2ad0a4d..66c53e1 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -84,3 +84,32 @@ if(LIBGPIOD_FOUND) endif() catch_discover_tests(ardulinux-i2c-tests) endif() + +# ─── LinuxGPIOPin tests ────────────────────────────────────────────────────── +# LinuxGPIOPin drives real GPIO character devices, which a unit test cannot +# open (/dev/gpiochip* needs hardware, or gpio-sim plus root). fake_gpiod.cpp +# defines the libgpiod symbols LinuxGPIOPin.cpp references; because those +# definitions come from an object file linked directly into this executable, +# they win over the shared library and no device is ever touched. +# +# A separate executable is required so the fake's gpiod_* symbols cannot +# displace the real ones in ardulinux-tests, which links libgpiod for the I2C +# and SPI paths. Built only when LIBGPIOD_FOUND, since LinuxGPIOPin.cpp is +# guarded by ARDULINUX_HARDWARE and needs . +if(LIBGPIOD_FOUND) + add_executable(ardulinux-gpio-tests + test_linux_gpio.cpp + fake_gpiod.cpp + ${CMAKE_SOURCE_DIR}/cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp + ) + target_link_libraries(ardulinux-gpio-tests PRIVATE ardulinux-base Catch2::Catch2WithMain) + target_include_directories(ardulinux-gpio-tests PRIVATE + ${CMAKE_SOURCE_DIR}/cores/ardulinux + ${CMAKE_CURRENT_SOURCE_DIR} + ) + if(CMAKE_BUILD_TYPE STREQUAL "Coverage") + target_compile_options(ardulinux-gpio-tests PRIVATE --coverage -O0 -g) + target_link_options(ardulinux-gpio-tests PRIVATE --coverage) + endif() + catch_discover_tests(ardulinux-gpio-tests) +endif() diff --git a/tests/unit/fake_gpiod.cpp b/tests/unit/fake_gpiod.cpp new file mode 100644 index 0000000..58d0e0f --- /dev/null +++ b/tests/unit/fake_gpiod.cpp @@ -0,0 +1,317 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * @file fake_gpiod.cpp + * @brief In-process stand-in for the libgpiod C API, v1 and v2. + * + * Defines every libgpiod symbol LinuxGPIOPin.cpp references. Because these + * definitions come from an object file linked directly into the test binary, + * they take precedence over the shared library, so no GPIO character device is + * ever opened. Behaviour is steered through the global ::fake instance. + * + * Which API is defined is decided by the same probe LinuxGPIOPin.h uses, so the + * fake always matches the branch of LinuxGPIOPin.cpp that got compiled. + * + * The opaque libgpiod structs are given trivial definitions here; the code + * under test only ever passes the pointers back, never dereferences them. + */ + +#include "fake_gpiod.h" + +#include +#include +#include + +/** gpiod v1 defines GPIOD_LINE_BULK_MAX_LINES; v2 does not. */ +#ifndef GPIOD_LINE_BULK_MAX_LINES +#define FAKE_GPIOD_V 2 +#else +#define FAKE_GPIOD_V 1 +#endif + +FakeGpiod fake; + +struct gpiod_chip { int unused; }; + +/// Singleton chip handle handed out by the fake; identity is all tests need. +static gpiod_chip g_chip; + +extern "C" { + +struct gpiod_chip *gpiod_chip_open(const char *path) +{ + (void) path; + if (fake.chip_open_fails) { + errno = fake.fail_errno; + return NULL; + } + fake.chip_open_count++; + return &g_chip; +} + +void gpiod_chip_close(struct gpiod_chip *chip) +{ + // Mirrors libgpiod >= 2.0, which returns early on NULL rather than + // aborting. Counted separately so tests can prove the destructor never + // closes a live handle twice. + if (!chip) { + fake.chip_close_null_count++; + return; + } + fake.chip_close_count++; +} + +} // extern "C" + +#if FAKE_GPIOD_V == 2 + +// ─── libgpiod v2 ───────────────────────────────────────────────────────────── + +struct gpiod_chip_info { int unused; }; +struct gpiod_line_settings { int unused; }; +struct gpiod_line_config { int unused; }; +struct gpiod_request_config { int unused; }; +struct gpiod_line_request { int unused; }; + +static gpiod_line_request g_request; + +extern "C" { + +struct gpiod_chip_info *gpiod_chip_get_info(struct gpiod_chip *chip) +{ + (void) chip; + return NULL; // the label scan is not exercised; the /dev fast path is used +} + +void gpiod_chip_info_free(struct gpiod_chip_info *info) { (void) info; } + +const char *gpiod_chip_info_get_label(struct gpiod_chip_info *info) +{ + (void) info; + return NULL; +} + +int gpiod_chip_get_line_offset_from_name(struct gpiod_chip *chip, const char *name) +{ + (void) chip; + fake.last_line_name = name; + if (fake.name_lookup_offset < 0) + errno = ENOENT; + return fake.name_lookup_offset; +} + +struct gpiod_line_settings *gpiod_line_settings_new(void) +{ + return (struct gpiod_line_settings *) malloc(sizeof(struct gpiod_line_settings)); +} + +void gpiod_line_settings_free(struct gpiod_line_settings *settings) { free(settings); } + +int gpiod_line_settings_set_direction(struct gpiod_line_settings *settings, + enum gpiod_line_direction direction) +{ + (void) settings; + switch (direction) { + case GPIOD_LINE_DIRECTION_INPUT: fake.last_direction = FakeDirInput; break; + case GPIOD_LINE_DIRECTION_OUTPUT: fake.last_direction = FakeDirOutput; break; + default: fake.last_direction = FakeDirAsIs; break; + } + return 0; +} + +int gpiod_line_settings_set_output_value(struct gpiod_line_settings *settings, + enum gpiod_line_value value) +{ + (void) settings; + fake.last_output_value = (int) value; + return 0; +} + +struct gpiod_line_config *gpiod_line_config_new(void) +{ + return (struct gpiod_line_config *) malloc(sizeof(struct gpiod_line_config)); +} + +void gpiod_line_config_free(struct gpiod_line_config *config) { free(config); } + +int gpiod_line_config_add_line_settings(struct gpiod_line_config *config, + const unsigned int *offsets, size_t num_offsets, + struct gpiod_line_settings *settings) +{ + (void) config; + (void) settings; + if (num_offsets) + fake.last_offset = offsets[0]; + return fake.add_line_settings_ret; +} + +struct gpiod_request_config *gpiod_request_config_new(void) +{ + return (struct gpiod_request_config *) malloc(sizeof(struct gpiod_request_config)); +} + +void gpiod_request_config_free(struct gpiod_request_config *config) { free(config); } + +void gpiod_request_config_set_consumer(struct gpiod_request_config *config, + const char *consumer) +{ + (void) config; + fake.last_consumer = consumer; +} + +struct gpiod_line_request *gpiod_chip_request_lines(struct gpiod_chip *chip, + struct gpiod_request_config *req_cfg, + struct gpiod_line_config *line_cfg) +{ + (void) chip; + (void) req_cfg; + (void) line_cfg; + if (fake.request_lines_fails) { + errno = fake.fail_errno; + return NULL; + } + return &g_request; +} + +void gpiod_line_request_release(struct gpiod_line_request *request) +{ + (void) request; + fake.line_release_count++; +} + +enum gpiod_line_value gpiod_line_request_get_value(struct gpiod_line_request *request, + unsigned int offset) +{ + (void) request; + (void) offset; + if (fake.get_value_ret < 0) + errno = fake.fail_errno; + return (enum gpiod_line_value) fake.get_value_ret; +} + +int gpiod_line_request_set_value(struct gpiod_line_request *request, unsigned int offset, + enum gpiod_line_value value) +{ + (void) request; + (void) offset; + fake.last_written = (int) value; + if (fake.set_value_ret != 0) + errno = fake.fail_errno; + return fake.set_value_ret; +} + +int gpiod_line_request_reconfigure_lines(struct gpiod_line_request *request, + struct gpiod_line_config *config) +{ + (void) request; + (void) config; + if (fake.reconfigure_ret != 0) + errno = fake.fail_errno; + return fake.reconfigure_ret; +} + +} // extern "C" + +#else + +// ─── libgpiod v1 ───────────────────────────────────────────────────────────── +// +// v1 has no separate request object: gpiod_line is both the line and the +// request, and direction is expressed by which request function is called +// rather than by a settings object. + +struct gpiod_line { int unused; }; + +static gpiod_line g_line; + +extern "C" { + +const char *gpiod_chip_label(struct gpiod_chip *chip) +{ + (void) chip; + return NULL; // the label scan is not exercised; the /dev fast path is used +} + +struct gpiod_line *gpiod_chip_find_line(struct gpiod_chip *chip, const char *name) +{ + (void) chip; + fake.last_line_name = name; + return &g_line; +} + +struct gpiod_line *gpiod_chip_get_line(struct gpiod_chip *chip, unsigned int offset) +{ + (void) chip; + fake.last_offset = offset; + return &g_line; +} + +int gpiod_line_request(struct gpiod_line *line, + const struct gpiod_line_request_config *config, int default_val) +{ + (void) line; + (void) default_val; + if (config) + fake.last_consumer = config->consumer; + fake.last_direction = FakeDirAsIs; + if (fake.request_lines_fails) { + errno = fake.fail_errno; + return -1; + } + return 0; +} + +void gpiod_line_release(struct gpiod_line *line) +{ + (void) line; + fake.line_release_count++; +} + +int gpiod_line_get_value(struct gpiod_line *line) +{ + (void) line; + if (fake.get_value_ret < 0) + errno = fake.fail_errno; + return fake.get_value_ret; +} + +int gpiod_line_set_value(struct gpiod_line *line, int value) +{ + (void) line; + fake.last_written = value; + if (fake.set_value_ret != 0) + errno = fake.fail_errno; + return fake.set_value_ret; +} + +int gpiod_line_request_output(struct gpiod_line *line, const char *consumer, + int default_val) +{ + (void) line; + fake.last_consumer = consumer; + fake.last_direction = FakeDirOutput; + fake.last_output_value = default_val; + return fake.reconfigure_ret; +} + +int gpiod_line_request_input(struct gpiod_line *line, const char *consumer) +{ + (void) line; + fake.last_consumer = consumer; + fake.last_direction = FakeDirInput; + return fake.reconfigure_ret; +} + +int gpiod_line_set_flags(struct gpiod_line *line, int flags) +{ + (void) line; + (void) flags; + return fake.reconfigure_ret; +} + +} // extern "C" + +#endif diff --git a/tests/unit/fake_gpiod.h b/tests/unit/fake_gpiod.h new file mode 100644 index 0000000..04aab60 --- /dev/null +++ b/tests/unit/fake_gpiod.h @@ -0,0 +1,86 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +#pragma once + +/** + * @file fake_gpiod.h + * @brief Control surface for the in-process libgpiod fake. + * + * LinuxGPIOPin talks to real GPIO character devices, which unit tests cannot + * open: /dev/gpiochip* requires hardware (or the gpio-sim kernel module plus + * root). fake_gpiod.cpp instead defines the libgpiod symbols that + * LinuxGPIOPin.cpp references, so the test binary links against the fake and + * never reaches the real library. Tests drive the error paths by setting the + * knobs below and assert on the counters afterwards. + * + * The fake implements **both** library APIs, selected by the same probe + * LinuxGPIOPin.h uses, because the two are installed on different machines that + * matter: CI runs libgpiod 1.x (Ubuntu ships 1.6.3) while the deployment + * targets run 2.x. Where the two APIs express the same intent differently, the + * fake normalises it -- see FakeDirection -- so a test asserting on behavior + * common to both versions needs no `#if`. + * + * @see fake_gpiod.cpp for the symbol definitions. + */ + +/** + * Line direction, normalised across the two libgpiod APIs. + * + * v2 expresses direction as a gpiod_line_settings property; v1 expresses it by + * which request function is called. The fake records this enum either way. + */ +enum FakeDirection { + FakeDirUnset = -1, ///< nothing has configured a direction yet + FakeDirAsIs = 0, ///< requested without changing the line's direction + FakeDirInput = 1, ///< configured as an input + FakeDirOutput = 2, ///< configured as an output +}; + +/** Knobs and counters for the fake libgpiod used by the LinuxGPIOPin tests. */ +struct FakeGpiod { + // ─── Knobs: make a libgpiod call fail ──────────────────────────────────── + + /** Make the chip open fail (chip missing or not permitted). */ + bool chip_open_fails = false; + /** + * Make line acquisition fail, i.e. the line is already claimed. + * + * Normalised across versions: v2's gpiod_chip_request_lines() returns NULL, + * v1's gpiod_line_request() returns -1. + */ + bool request_lines_fails = false; + /** Offset reported by gpiod_chip_get_line_offset_from_name(); -1 = no such name. (v2) */ + int name_lookup_offset = 7; + /** Return value of gpiod_line_config_add_line_settings(). (v2) */ + int add_line_settings_ret = 0; + /** Value reported by the line read; -1 = the library's error sentinel. */ + int get_value_ret = 1; + /** Return value of the line write; non-zero signals failure. */ + int set_value_ret = 0; + /** Return value of gpiod_line_request_reconfigure_lines(). (v2) */ + int reconfigure_ret = 0; + /** errno the fake sets before returning a failure, so strerror() has input. */ + int fail_errno = 16 /* EBUSY */; + + // ─── Counters and captured arguments ───────────────────────────────────── + + int chip_open_count = 0; ///< successful chip opens + int chip_close_count = 0; ///< chip closes with a live handle + int chip_close_null_count = 0; ///< chip closes with NULL (v2 only; see getLine) + int line_release_count = 0; ///< line releases + unsigned last_offset = 0; ///< offset the line was requested at + const char *last_line_name = nullptr; ///< name the line was looked up by + int last_direction = FakeDirUnset; ///< normalised direction last configured + int last_output_value = -1; ///< output value last seeded + int last_written = -1; ///< value last written to the line + const char *last_consumer = nullptr; ///< consumer label last set + + /** Restore every knob and counter to its default. Call at test start. */ + void reset() { *this = FakeGpiod(); } +}; + +/** The single fake instance shared by the fake symbols and the tests. */ +extern FakeGpiod fake; diff --git a/tests/unit/test_linux_gpio.cpp b/tests/unit/test_linux_gpio.cpp new file mode 100644 index 0000000..a9a9a4d --- /dev/null +++ b/tests/unit/test_linux_gpio.cpp @@ -0,0 +1,515 @@ +// ArduLinux - Arduino API for Linux +// Copyright (c) 2026-27 l5yth +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * @file test_linux_gpio.cpp + * @brief Unit tests for LinuxGPIOPin against a faked libgpiod v2. + * + * Every libgpiod call LinuxGPIOPin makes is served by fake_gpiod.cpp, so these + * tests exercise the real LinuxGPIOPin.cpp source without a GPIO character + * device. The chip label "null" is used throughout: find_chip_by_label() tries + * "/dev/