From b3ea3341682b671cdb365d55e0cb041a7995de5b Mon Sep 17 00:00:00 2001 From: mmmorks Date: Mon, 7 Sep 2026 14:19:36 -0700 Subject: [PATCH] variants/linux: block on the LoRa IRQ instead of spinning; non-blocking CAD ardulinux skips its loop sleep whenever real hardware is bound, so the main loop ran flat out; #24 turned that into a 1 ms sleep per iteration, which is a fixed-rate poll of the radio rather than an idle. Block on the radio instead: bind DIO1 as an EventGPIOPin -- an ardulinux GPIOPin that also exposes a libgpiod rising-edge event descriptor (v1 and v2) -- and have loop() end in poll() on that descriptor until the IRQ fires or a bounded ceiling elapses. Idle CPU drops from a busy core to well under 1% with edge detection (1-3% in the polling fallback), with no added packet latency: DIO1 carries both RX-done and TX-done, so packet events wake the loop at once. #24's sleep() and delay(1) stay; on Linux the 1 ms delay is now redundant, since the poll() wait follows it. The seam is a new MainBoard::idleUntilEvent(max_wait_ms), default no-op, with the implementer's contract written down: never lose an IRQ that is already asserted on entry (a level-latched DIO1 that went high beforehand may produce no further edge -- ESP32Board::sleep() already checks gpio_get_level() for the same reason), and never wait on a descriptor the caller will not drain (POLLIN is level-triggered, so an undrained one turns the wait back into a busy loop). LinuxBoard's implementation re-reads the IRQ level via digitalRead() before blocking -- in ardulinux that refreshes the cached level gpioIdle() fires the ISR against, and it is what makes a ceiling longer than a packet's airtime safe. The ceiling is 50 ms: the floor of Dispatcher's delayed-inbound queue, and everything faster arrives on the IRQ. LinuxEventLoop backs off 1 ms on POLLNVAL/POLLHUP/poll() failure, because poll() returns a positive count for those and a single closed descriptor would otherwise reinstate the spin. The same descriptor fixes hardware CAD. RadioLib's scanChannel(), which performChannelScan() calls, spins on digitalRead(DIO1) with no deadline: on Linux that burns a core for the length of every scan, and would turn EventGPIOPin's deliberate read-fails-as-LOW degradation into an unbreakable hang. The override splits the scan into startChannelScan(), a sleep on the edge with a deadline derived from the active SF/BW (8 symbol times plus 20 ms), and getChannelScanResult() -- read over SPI regardless of whether the line reported, so a dead line costs latency and a log line, never a wrong answer. It stays synchronous on purpose: isChannelActive() has to answer "is the channel clear right now", and CAD puts the modem in standby, so there is nothing to overlap with. This depends on CAD_DONE being in the DIO1 routing mask alongside CAD_DETECTED (a free channel arrives as an edge, not a timeout); verified against the pinned RadioLib. symbolMicros() moves out of calcMaxPacketMillis() so the two share one formula. Both LinuxEventLoop and the wait (LinuxRadioWait) depend only on POSIX -- no RadioLib, no libgpiod, no Arduino -- so they compile into the native gtest env. The tests cover the anti-spin properties directly (a removed backoff or an unfiltered poll() count fails them), drive real signals through the wait for EINTR, and check the INT_MAX clamp on the timeout. Run on a Pi with the Waveshare SX1262 HAT on bookworm (libgpiod 1.6.3). The libgpiod v2 path builds in the trixie container but has not been run on hardware; the README says so. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4 --- examples/simple_repeater/main.cpp | 26 + platformio.ini | 3 + src/MeshCore.h | 24 + src/helpers/radiolib/LinuxSX1262Wrapper.h | 60 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 2 +- src/helpers/radiolib/RadioLibWrappers.h | 3 + .../test_linux_event_loop.cpp | 290 +++++++++++ .../test_linux_radio_wait.cpp | 357 ++++++++++++++ variants/linux/EventGPIOPin.cpp | 462 ++++++++++++++++++ variants/linux/EventGPIOPin.h | 109 +++++ variants/linux/LinuxBoard.cpp | 121 ++++- variants/linux/LinuxBoard.h | 25 + variants/linux/LinuxEventLoop.cpp | 85 ++++ variants/linux/LinuxEventLoop.h | 51 ++ variants/linux/LinuxEventSource.h | 20 + variants/linux/LinuxRadioWait.cpp | 69 +++ variants/linux/LinuxRadioWait.h | 53 ++ variants/linux/README.md | 62 +++ 18 files changed, 1820 insertions(+), 2 deletions(-) create mode 100644 test/test_linux_event_loop/test_linux_event_loop.cpp create mode 100644 test/test_linux_radio_wait/test_linux_radio_wait.cpp create mode 100644 variants/linux/EventGPIOPin.cpp create mode 100644 variants/linux/EventGPIOPin.h create mode 100644 variants/linux/LinuxEventLoop.cpp create mode 100644 variants/linux/LinuxEventLoop.h create mode 100644 variants/linux/LinuxEventSource.h create mode 100644 variants/linux/LinuxRadioWait.cpp create mode 100644 variants/linux/LinuxRadioWait.h diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a556062881..b44f6a6217 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -30,6 +30,26 @@ static char ethernet_command[160]; // For power saving unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot +// How long loop() is willing to idle between iterations, for boards that +// implement MainBoard::idleUntilEvent(). +// +// The bound is the shortest deadline not already delivered by the radio IRQ, +// and there are two. Dispatcher::getCADFailRetryDelay() is 200 ms, which 50 ms +// clears with 4x margin. The delayed-inbound queue is the tighter one: its +// delay is randomised per packet but floored at exactly 50 ms, because +// checkRecv() processes anything below that immediately rather than queueing +// it. So a queued inbound packet can be serviced up to one full iteration late. +// +// That is latency, not error. The delay being quantised is a randomised +// collision-spreading interval, and nodes do not wake in step with one another, +// so rounding it up adds jitter to a quantity that is already jitter -- it +// cannot bunch two nodes onto the same slot the way a synchronised delay would. +// Nothing else needs a faster iteration: RX-done and TX-done arrive on the IRQ. +// Boards with no implementation ignore this entirely and keep busy-looping. +#ifndef IDLE_MAX_WAIT_MS + #define IDLE_MAX_WAIT_MS 50 +#endif + #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) static unsigned long userBtnDownAt = 0; #define USER_BTN_HOLD_OFF_MILLIS 1500 @@ -213,4 +233,10 @@ void loop() { // Small delay to prevent busy loop on platforms without power saving delay(1); } + + // Idle instead of spinning between iterations. Default implementation is a + // no-op, so this is safe on every board; those that implement it block on + // the radio IRQ (and any other descriptor they will drain) until it fires or + // IDLE_MAX_WAIT_MS elapses. + board.idleUntilEvent(IDLE_MAX_WAIT_MS); } diff --git a/platformio.ini b/platformio.ini index 6f41c4b74c..0878e438c8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -219,6 +219,7 @@ test_framework = googletest build_flags = -std=c++17 -I src -I test/mocks + -I variants/linux test_build_src = yes test_ignore = test_kiss_modem build_src_filter = @@ -227,6 +228,8 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../variants/linux/LinuxEventLoop.cpp> + +<../variants/linux/LinuxRadioWait.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/MeshCore.h b/src/MeshCore.h index 4349523225..b38f77a0f3 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -59,6 +59,30 @@ class MainBoard { virtual void onBootComplete() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) virtual void sleep(uint32_t secs) { /* no op */ } + + /** + * Idle until an event that needs servicing arrives -- the radio IRQ, or any + * other descriptor the platform knows the caller will drain this iteration -- + * or until max_wait_ms elapses, whichever comes first. + * + * Returning early, or immediately, is ALWAYS correct: loop() re-checks all + * state on every iteration, so this is a pure "don't spin" hint and never a + * source of scheduling guarantees. Two obligations for implementers: + * + * - Do not lose an IRQ that is already asserted on entry. A level-latched + * line (SX1262 DIO1) that went high before the wait began may produce no + * further edge, so check the level first and return immediately if it is + * set. ESP32Board::sleep() does this via gpio_get_level(). + * - Do not wait on a descriptor the caller will not drain, or the wait + * returns instantly forever and the loop spins anyway. + * + * Distinct from sleep(): this keeps peripherals live and is always safe to + * call, whereas sleep() is an opt-in deep sleep that may drop them. + * + * Default no-op: boards that don't implement it keep the historical + * busy-loop behaviour. + */ + virtual void idleUntilEvent(uint32_t max_wait_ms) { /* no op */ } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} virtual uint8_t getStartupReason() const = 0; diff --git a/src/helpers/radiolib/LinuxSX1262Wrapper.h b/src/helpers/radiolib/LinuxSX1262Wrapper.h index 7bf90d2f3b..dd14264df7 100644 --- a/src/helpers/radiolib/LinuxSX1262Wrapper.h +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -1,15 +1,33 @@ #pragma once #include "LinuxSX1262.h" +#include "LinuxRadioWait.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" class LinuxSX1262Wrapper : public RadioLibWrapper { + // How long performChannelScan() will wait for DIO1 before giving up on the + // line and reading the result over SPI. Set from the active SF/BW by + // setParams(); the initial value covers only the window before the first + // call, so it is seeded from the slowest scan a MeshCore preset can produce + // (SF12 at 62.5 kHz) rather than a hand-checked constant. CAD cannot actually + // run in that window -- _cad_enabled stays false until Dispatcher::loop() + // first pushes it -- so this is belt-and-braces rather than a live value. + uint32_t _cad_timeout_ms = cadTimeoutMillis(symbolMicros(12, 62.5f)); + // _radio is held as the base mesh::Radio, so every use here needs the // downcast. It is always a LinuxSX1262 -- the constructor takes one by // reference -- so this is a naming convenience, not a checked conversion. LinuxSX1262* r() const { return (LinuxSX1262 *)_radio; } + // Same for the board. waitForRadioIrq() is LinuxBoard's, not + // mesh::MainBoard's, and this reaches it through the member the wrapper was + // constructed with rather than through the `board` global LinuxSX1262.h + // declares. Those are the same object today; going through the member is what + // keeps them the same object if a second instance is ever constructed, and + // stops this file quietly depending on a global it does not own. + LinuxBoard* b() const { return (LinuxBoard *)_board; } + public: LinuxSX1262Wrapper(LinuxSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } @@ -22,6 +40,48 @@ class LinuxSX1262Wrapper : public RadioLibWrapper { PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); r()->setPreambleMillis(pm.preambleMillis); r()->setMaxPayloadMillis(pm.payloadMillis); + _cad_timeout_ms = cadTimeoutMillis(symbolMicros(sf, bw)); + } + + // Hardware CAD without RadioLib's busy-wait. + // + // The base implementation calls scanChannel(), which spins on + // digitalRead(DIO1) until the line rises. On an MCU with nothing else to do + // that is merely wasteful; here it burns a core for the length of every scan, + // against an event loop built to sleep, and -- because it has no deadline -- + // it turns a GPIO read that has started failing into an unbreakable hang. + // EventGPIOPin deliberately reads LOW on failure so a broken line degrades to + // "no packet" plus one logged error; inside an untimed spin that same failure + // would lock up the daemon. + // + // Splitting the scan into start / wait / read fixes both. Nothing is lost by + // blocking here: startChannelScan() puts the modem in standby first, so no + // packet can arrive during the scan and there is nothing for the loop to + // overlap with. + int16_t performChannelScan() override { + // Same configuration scanChannel() used: 4 symbols, exit to STDBY_RC, and + // DIO1 mapped to CAD_DONE | CAD_DETECTED. CAD_DONE being in that mask is + // what the wait below depends on -- the line rises however the scan + // resolves, so a free channel arrives as an edge and not as a timeout. + // startChannelScan() also clears the IRQ status, so DIO1 is low on entry. + int16_t state = r()->startChannelScan(); + if (state != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("LinuxSX1262Wrapper: startChannelScan() failed (%d)", state); + return state; // isChannelActive() reads anything but CHANNEL_FREE as busy + } + + if (!b()->waitForRadioIrq(_cad_timeout_ms)) { + // Logged every time rather than latched: the rate is bounded by transmit + // attempts, and a line that has stopped reporting should stay visible for + // as long as it is broken. + MESH_DEBUG_PRINTLN("LinuxSX1262Wrapper: CAD IRQ did not arrive within %ums", _cad_timeout_ms); + } + + // Read the verdict whether or not DIO1 reported it. getChannelScanResult() + // goes over SPI to the modem's IRQ status register, which is authoritative + // and wholly independent of the GPIO -- so a dead line costs latency and a + // log line, never a wrong answer, and never a hang. + return r()->getChannelScanResult(); } // Full SX126x receiver reset (warm sleep, recalibrate, re-image the configured diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c27..2a0ff7b911 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -243,7 +243,7 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) { // based on RadioLib's calculateTimeOnAir() - uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10); + uint32_t tsym_us = symbolMicros(sf, bw); uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd // preamble + syncword + sfd + header diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b..0cae319c92 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -54,6 +54,9 @@ class RadioLibWrapper : public mesh::Radio { virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } + // LoRa symbol time in microseconds, for a spreading factor and a bandwidth in + // kHz. Every airtime and timeout derived from the modem's rate starts here. + static uint32_t symbolMicros(uint8_t sf, float bw) { return ((uint32_t)10000 << sf) / (bw * 10); } void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols); virtual int16_t performChannelScan(); diff --git a/test/test_linux_event_loop/test_linux_event_loop.cpp b/test/test_linux_event_loop/test_linux_event_loop.cpp new file mode 100644 index 0000000000..9087bb383b --- /dev/null +++ b/test/test_linux_event_loop/test_linux_event_loop.cpp @@ -0,0 +1,290 @@ +#include + +#include +#include +#include +#include + +#include "LinuxEventLoop.h" + +namespace { + +// Event source backed by a pipe, so a test can make it readable on demand. +class FakePipeSource : public LinuxEventSource { +public: + FakePipeSource() { + if (pipe(_fds) != 0) { _fds[0] = -1; _fds[1] = -1; return; } + // Make both ends non-blocking for drainEvents() to work correctly + fcntl(_fds[0], F_SETFL, fcntl(_fds[0], F_GETFL, 0) | O_NONBLOCK); + fcntl(_fds[1], F_SETFL, fcntl(_fds[1], F_GETFL, 0) | O_NONBLOCK); + } + ~FakePipeSource() override { + if (_fds[0] >= 0) close(_fds[0]); + if (_fds[1] >= 0) close(_fds[1]); + } + + int eventFd() const override { return _enabled ? _fds[0] : -1; } + + void drainEvents() override { + unsigned char buf[64]; + while (::read(_fds[0], buf, sizeof buf) > 0) { } + drain_calls++; + } + + // Make the source readable. + void signal() { + unsigned char b = 1; + ssize_t n = ::write(_fds[1], &b, 1); + (void)n; + } + + void disable() { _enabled = false; } + + int pendingBytes() { + int fl = fcntl(_fds[0], F_GETFL, 0); + fcntl(_fds[0], F_SETFL, fl | O_NONBLOCK); + unsigned char buf[64]; + ssize_t n = ::read(_fds[0], buf, sizeof buf); + return n > 0 ? (int)n : 0; + } + + int drain_calls = 0; + +private: + int _fds[2]; + bool _enabled = true; +}; + +// A readable descriptor that is NOT the event source. +class FakePipeFd { +public: + FakePipeFd() { + if (pipe(_fds) != 0) { _fds[0] = -1; _fds[1] = -1; return; } + // Make read end non-blocking + fcntl(_fds[0], F_SETFL, fcntl(_fds[0], F_GETFL, 0) | O_NONBLOCK); + } + ~FakePipeFd() { + if (_fds[0] >= 0) close(_fds[0]); + if (_fds[1] >= 0) close(_fds[1]); + } + int readFd() const { return _fds[0]; } + void signal() { + unsigned char b = 1; + ssize_t n = ::write(_fds[1], &b, 1); + (void)n; + } +private: + int _fds[2]; +}; + +} // namespace + +TEST(LinuxEventLoopRegister, IgnoresNegativeDescriptors) { + LinuxEventLoop loop; + loop.reset(); + loop.registerFd(-1); + loop.registerFd(-42); + EXPECT_EQ(0, loop.registeredCount()); +} + +TEST(LinuxEventLoopRegister, IgnoresDuplicates) { + LinuxEventLoop loop; + loop.reset(); + int fd = open("/dev/null", O_RDONLY); + ASSERT_GE(fd, 0); + loop.registerFd(fd); + loop.registerFd(fd); + EXPECT_EQ(1, loop.registeredCount()); + close(fd); +} + +TEST(LinuxEventLoopRegister, CapsAtMaxFds) { + LinuxEventLoop loop; + loop.reset(); + int fds[LinuxEventLoop::MAX_FDS + 3]; + for (int i = 0; i < LinuxEventLoop::MAX_FDS + 3; i++) { + fds[i] = open("/dev/null", O_RDONLY); + ASSERT_GE(fds[i], 0); + loop.registerFd(fds[i]); + } + EXPECT_EQ(LinuxEventLoop::MAX_FDS, loop.registeredCount()); + for (int i = 0; i < LinuxEventLoop::MAX_FDS + 3; i++) close(fds[i]); +} + +TEST(LinuxEventLoopRegister, ResetClearsEverything) { + LinuxEventLoop loop; + FakePipeSource source; + loop.reset(); + loop.registerFd(source.eventFd()); + loop.setEventSource(&source); + loop.reset(); + EXPECT_EQ(0, loop.registeredCount()); + // With no source and no fds, wait() must still honour the timeout. + EXPECT_EQ(0, loop.wait(1)); +} + +TEST(LinuxEventLoopWait, TimesOutWithNothingRegistered) { + LinuxEventLoop loop; + loop.reset(); + EXPECT_EQ(0, loop.wait(1)); +} + +TEST(LinuxEventLoopWait, WakesOnEventSourceAndDrainsIt) { + LinuxEventLoop loop; + FakePipeSource source; + loop.reset(); + loop.setEventSource(&source); + + source.signal(); + EXPECT_GE(loop.wait(1000), 1); + EXPECT_EQ(1, source.drain_calls); + // Draining must have emptied the descriptor, otherwise poll() would spin. + EXPECT_EQ(0, source.pendingBytes()); +} + +TEST(LinuxEventLoopWait, DisabledSourceIsNotPolled) { + LinuxEventLoop loop; + FakePipeSource source; + loop.reset(); + loop.setEventSource(&source); + source.signal(); + source.disable(); // eventFd() now returns -1 + + EXPECT_EQ(0, loop.wait(1)); + EXPECT_EQ(0, source.drain_calls); +} + +TEST(LinuxEventLoopWait, WakesOnRegisteredFdWithoutDraining) { + LinuxEventLoop loop; + FakePipeSource source; + FakePipeFd other; + loop.reset(); + loop.setEventSource(&source); + loop.registerFd(other.readFd()); + + other.signal(); + EXPECT_GE(loop.wait(1000), 1); + // Only the event source gets drained; plain descriptors are the caller's job. + EXPECT_EQ(0, source.drain_calls); +} + +TEST(LinuxEventLoopWait, NullSourceIsSafe) { + LinuxEventLoop loop; + FakePipeFd other; + loop.reset(); + loop.setEventSource(nullptr); + loop.registerFd(other.readFd()); + other.signal(); + EXPECT_GE(loop.wait(1000), 1); +} + +TEST(LinuxEventLoopWait, StaleDescriptorReportsNothingReadable) { + LinuxEventLoop loop; + loop.reset(); + int fd = open("/dev/null", O_RDONLY); + ASSERT_GE(fd, 0); + loop.registerFd(fd); + close(fd); // stale: poll() sets POLLNVAL and returns a POSITIVE count + + // Passing that count up would make the caller loop with no delay, which is + // exactly the busy-wait this class removes. + EXPECT_EQ(0, loop.wait(0)); +} + +TEST(LinuxEventLoopWait, HungUpDescriptorDoesNotSpinTheLoop) { + // The POLLHUP sibling of the POLLNVAL case above: a pipe whose write end is + // closed reports its hangup on every poll() and never clears, so passing that + // count up would reinstate the busy loop. + // + // Platforms disagree on the revents. Linux reports POLLHUP alone. macOS -- + // where this native build runs -- also sets POLLIN, because read() returns 0 + // without blocking and its poll() calls that readable; there the descriptor + // is genuinely drainable and one wake is the right answer. What must hold + // everywhere is that a hung-up descriptor never yields a wake the caller + // cannot clear, so assert the platform's own view of readability. + LinuxEventLoop loop; + loop.reset(); + + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + close(fds[1]); // hangup, with no data behind it + loop.registerFd(fds[0]); + + struct pollfd probe = { fds[0], POLLIN, 0 }; + ASSERT_GE(poll(&probe, 1, 0), 0); + int expected = (probe.revents & POLLIN) != 0 ? 1 : 0; + + EXPECT_EQ(expected, loop.wait(0)); + close(fds[0]); +} + +TEST(LinuxEventLoopWait, CountsOnlyReadableDescriptors) { + LinuxEventLoop loop; + FakePipeFd a; + FakePipeFd b; + loop.reset(); + loop.registerFd(a.readFd()); + loop.registerFd(b.readFd()); + + a.signal(); // only one of the two becomes readable + EXPECT_EQ(1, loop.wait(1000)); +} + +TEST(LinuxEventLoopWait, BackoffSleepsOnStaleDescriptor) { + // Discriminator: proves usleep(EVENT_LOOP_ERROR_BACKOFF_US) actually executes. + // Removing that line would fail this test. + LinuxEventLoop loop; + loop.reset(); + + int fd = open("/dev/null", O_RDONLY); + ASSERT_GE(fd, 0); + loop.registerFd(fd); + close(fd); // stale descriptor + + struct timespec start, end; + clock_gettime(CLOCK_MONOTONIC, &start); + + EXPECT_EQ(0, loop.wait(0)); // zero timeout, but should sleep due to stale descriptor + + clock_gettime(CLOCK_MONOTONIC, &end); + + // Calculate elapsed time in microseconds + long elapsed_us = (end.tv_sec - start.tv_sec) * 1000000 + + (end.tv_nsec - start.tv_nsec) / 1000; + + // Assert at least ~500 µs elapsed (generous margin under 1000 µs backoff) + EXPECT_GE(elapsed_us, 500); +} + +TEST(LinuxEventLoopWait, FiltersPollResultsNotRaw) { + // Discriminator: proves we count only POLLIN, not raw poll() return value. + // An implementation returning unfiltered poll() count would return 2 here + // (POLLIN on live fd + POLLNVAL on stale fd), failing this assertion. + LinuxEventLoop loop; + FakePipeFd live; + + loop.reset(); + + // Register the live fd first, then create and close a stale one. + // This ensures we can control which fd gets what number and avoid reuse. + int live_num = live.readFd(); + loop.registerFd(live_num); + + // Create and close a stale fd. Since we're holding the live fd, + // the stale fd number will differ and won't be immediately reused. + int stale = open("/dev/null", O_RDONLY); + ASSERT_GE(stale, 0); + loop.registerFd(stale); + close(stale); // Now stale is closed (POLLNVAL), live is still open + + live.signal(); // Make only the live fd readable + + // poll() would return 2 (POLLIN on live + POLLNVAL on stale). + // wait() should return 1 (only the POLLIN count, filtering out POLLNVAL). + EXPECT_EQ(1, loop.wait(0)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_linux_radio_wait/test_linux_radio_wait.cpp b/test/test_linux_radio_wait/test_linux_radio_wait.cpp new file mode 100644 index 0000000000..fb74e352f5 --- /dev/null +++ b/test/test_linux_radio_wait/test_linux_radio_wait.cpp @@ -0,0 +1,357 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "LinuxEventLoop.h" +#include "LinuxRadioWait.h" + +namespace { + +// Upper bound used wherever a test asserts "this did not block". Those waits +// are armed with a 5 s ceiling (or none at all) and should complete in +// microseconds to a few milliseconds; the number that matters is the gap to +// 5000, not tightness. Deliberately loose: the assertion is meant to catch a +// wait that slept out its deadline, not to measure a scheduler under load, and +// a millisecond-scale bound on a busy CI host tests the host rather than the +// code. Where the exact behaviour matters -- how many times the line was +// sampled, whether the source was drained -- the tests assert that directly. +const uint64_t DID_NOT_BLOCK_MS = 2000u; + +uint64_t nowMillis() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)(ts.tv_nsec / 1000000); +} + +// Event source backed by a pipe, so a test can make it readable on demand. +// Mirrors the fake in test_linux_event_loop. +class FakePipeSource : public LinuxEventSource { +public: + FakePipeSource() { + if (pipe(_fds) != 0) { _fds[0] = -1; _fds[1] = -1; return; } + fcntl(_fds[0], F_SETFL, fcntl(_fds[0], F_GETFL, 0) | O_NONBLOCK); + fcntl(_fds[1], F_SETFL, fcntl(_fds[1], F_GETFL, 0) | O_NONBLOCK); + } + ~FakePipeSource() override { + if (_fds[0] >= 0) close(_fds[0]); + if (_fds[1] >= 0) close(_fds[1]); + } + + int eventFd() const override { return _enabled ? _fds[0] : -1; } + + void drainEvents() override { + unsigned char buf[64]; + while (::read(_fds[0], buf, sizeof buf) > 0) { } + drain_calls++; + } + + void signal() { + unsigned char b = 1; + ssize_t n = ::write(_fds[1], &b, 1); + (void)n; + } + + // Simulate a line with no working edge detection: eventFd() returns -1. + void disable() { _enabled = false; } + + int pendingBytes() { + unsigned char buf[64]; + ssize_t n = ::read(_fds[0], buf, sizeof buf); + return n > 0 ? (int)n : 0; + } + + int drain_calls = 0; + +private: + int _fds[2]; + bool _enabled = true; +}; + +// Reports LOW for the first `assert_after` samples, then HIGH. +class FakeIrqLevel : public LinuxIrqLevel { +public: + explicit FakeIrqLevel(int assert_after = kNever) : assert_after(assert_after) { } + + bool irqAsserted() override { return ++calls > assert_after; } + + static const int kNever = 1000000; + + int assert_after; + int calls = 0; +}; + +const int FakeIrqLevel::kNever; + +// Delivers SIGALRM on a repeating interval for as long as it is in scope, so a +// wait running underneath it really does take EINTR out of poll(). +// +// The handler is installed WITHOUT SA_RESTART, which is the whole point: with +// it the kernel would restart poll() transparently and the test would prove +// nothing. A daemon gets signals it did not ask for -- SIGWINCH, a profiler's +// timer, whatever the supervisor sends -- and the CAD wait must survive them +// without returning early, spinning, or losing the line. +class SignalStorm { +public: + explicit SignalStorm(useconds_t interval_us) { + count = 0; + + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = &SignalStorm::onSignal; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; // no SA_RESTART: poll() must fail with EINTR + sigaction(SIGALRM, &sa, &_old_action); + + struct itimerval it; + it.it_interval.tv_sec = 0; + it.it_interval.tv_usec = interval_us; + it.it_value = it.it_interval; + setitimer(ITIMER_REAL, &it, &_old_timer); + } + + ~SignalStorm() { + struct itimerval off; + memset(&off, 0, sizeof off); + setitimer(ITIMER_REAL, &off, NULL); + sigaction(SIGALRM, &_old_action, NULL); + } + + static volatile sig_atomic_t count; + +private: + static void onSignal(int) { count++; } + + struct sigaction _old_action; + struct itimerval _old_timer; +}; + +volatile sig_atomic_t SignalStorm::count = 0; + +} // namespace + +TEST(WaitForIrqAsserted, ReturnsImmediatelyWhenLineIsAlreadyHigh) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level(0); // high on the very first sample + + uint64_t start = nowMillis(); + EXPECT_TRUE(waitForIrqAsserted(level, &source, loop, 5000)); + EXPECT_LT(nowMillis() - start, DID_NOT_BLOCK_MS); + // The real assertion: one sample and no poll at all, which is what "a scan + // that already finished costs nothing" actually means. + EXPECT_EQ(1, level.calls); +} + +TEST(WaitForIrqAsserted, WakesOnTheEventSourceAndRereadsTheLine) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level(1); // low once, high on the second sample + + source.signal(); // edge already queued, as it is when CAD finishes fast + + uint64_t start = nowMillis(); + EXPECT_TRUE(waitForIrqAsserted(level, &source, loop, 5000)); + EXPECT_LT(nowMillis() - start, DID_NOT_BLOCK_MS); + EXPECT_EQ(2, level.calls); + // The source must have been drained, otherwise poll() would return + // immediately forever on the next caller's watch. + EXPECT_GE(source.drain_calls, 1); + EXPECT_EQ(0, source.pendingBytes()); +} + +TEST(WaitForIrqAsserted, ReturnsFalseOnlyAfterTheFullTimeout) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level; // never asserts + + const uint32_t timeout_ms = 40; + uint64_t start = nowMillis(); + EXPECT_FALSE(waitForIrqAsserted(level, &source, loop, timeout_ms)); + uint64_t elapsed = nowMillis() - start; + + // Returning early would mean the deadline is not doing its job; returning + // very late would mean it is not bounded. + EXPECT_GE(elapsed + 2, timeout_ms); + EXPECT_LT(elapsed, timeout_ms + 500u); +} + +TEST(WaitForIrqAsserted, DoesNotSpinWhenTheSourceIsReadableButTheLineStaysLow) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level; // never asserts + + source.signal(); // a spurious/stale edge + + const uint32_t timeout_ms = 40; + uint64_t start = nowMillis(); + EXPECT_FALSE(waitForIrqAsserted(level, &source, loop, timeout_ms)); + EXPECT_GE(nowMillis() - start + 2, timeout_ms); + + // Draining is what stops the readable descriptor from turning the wait into + // a busy loop. Without it this test would still pass on time but would have + // spun through thousands of iterations to get there. + EXPECT_GE(source.drain_calls, 1); + EXPECT_EQ(0, source.pendingBytes()); +} + +TEST(WaitForIrqAsserted, HonoursTheDeadlineWithoutEdgeDetection) { + LinuxEventLoop loop; + FakePipeSource source; + source.disable(); // eventFd() == -1, as when the line has no edge support + FakeIrqLevel level; + + const uint32_t timeout_ms = 30; + uint64_t start = nowMillis(); + EXPECT_FALSE(waitForIrqAsserted(level, &source, loop, timeout_ms)); + uint64_t elapsed = nowMillis() - start; + + EXPECT_GE(elapsed + 2, timeout_ms); + EXPECT_LT(elapsed, timeout_ms + 500u); +} + +TEST(WaitForIrqAsserted, StillSeesTheLineWithoutEdgeDetection) { + LinuxEventLoop loop; + FakePipeSource source; + source.disable(); + FakeIrqLevel level(3); // asserts on the fourth sample, i.e. after 3 slices + + uint64_t start = nowMillis(); + EXPECT_TRUE(waitForIrqAsserted(level, &source, loop, 5000)); + // Polling fallback, so it costs a few milliseconds -- but nothing like the + // 5 s ceiling, which is what a fallback that slept for the whole remaining + // time would have done. Four samples is the tight assertion; the clock only + // has to separate 3 ms of slices from 5 s of sleeping. + EXPECT_LT(nowMillis() - start, DID_NOT_BLOCK_MS); + EXPECT_EQ(4, level.calls); +} + +// A signal that interrupts the poll() must not be mistaken for a timeout. +// LinuxEventLoop::wait() returns -1 on EINTR and the caller loops; what has to +// hold end to end is that the deadline still governs, so a node being signalled +// does not start reporting every channel free the moment a scan is armed. +TEST(WaitForIrqAsserted, SignalsDoNotEndTheWaitEarly) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level; // never asserts + + const uint32_t timeout_ms = 60; + bool asserted; + uint64_t elapsed; + { + SignalStorm storm(3000); // SIGALRM every 3 ms + uint64_t start = nowMillis(); + asserted = waitForIrqAsserted(level, &source, loop, timeout_ms); + elapsed = nowMillis() - start; + } + + EXPECT_FALSE(asserted); + EXPECT_GE(elapsed + 2, timeout_ms); + // Without this the test would still pass on a run where no signal happened to + // land inside the wait, and would be testing nothing. + EXPECT_GE((int)SignalStorm::count, 2) << "the wait was never actually interrupted"; +} + +// The other half: an interrupted wait must re-read the line rather than resume +// blocking on a stale sample. The IRQ may well have risen while the handler +// ran, and for CAD that edge is the entire answer. +TEST(WaitForIrqAsserted, SeesTheLineAfterAnInterruptedWait) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level(3); // asserts on the fourth sample + + bool asserted; + uint64_t elapsed; + { + SignalStorm storm(2000); // SIGALRM every 2 ms + uint64_t start = nowMillis(); + // Generous ceiling on purpose: nothing will ever make the pipe readable, so + // only the re-reads driven by EINTR can end this wait. + asserted = waitForIrqAsserted(level, &source, loop, 5000); + elapsed = nowMillis() - start; + } + + EXPECT_TRUE(asserted); + EXPECT_EQ(4, level.calls); + EXPECT_LT(elapsed, DID_NOT_BLOCK_MS); +} + +TEST(WaitForIrqAsserted, NullSourceIsSafe) { + LinuxEventLoop loop; + FakeIrqLevel level(2); + + EXPECT_TRUE(waitForIrqAsserted(level, nullptr, loop, 5000)); + EXPECT_EQ(3, level.calls); +} + +TEST(WaitForIrqAsserted, ZeroTimeoutStillReportsAnAssertedLine) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level(0); + + EXPECT_TRUE(waitForIrqAsserted(level, &source, loop, 0)); + EXPECT_EQ(1, level.calls); +} + +TEST(WaitForIrqAsserted, ZeroTimeoutReturnsWithoutWaiting) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level; + + uint64_t start = nowMillis(); + EXPECT_FALSE(waitForIrqAsserted(level, &source, loop, 0)); + EXPECT_LT(nowMillis() - start, DID_NOT_BLOCK_MS); + // Sampled once, and no poll: with no budget the deadline check must return + // before the wait, which is what the call count proves. + EXPECT_EQ(1, level.calls); +} + +TEST(WaitForIrqAsserted, ClearsAnyPreviouslyRegisteredDescriptors) { + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level(0); + + loop.reset(); + int fd = open("/dev/null", O_RDONLY); + ASSERT_GE(fd, 0); + loop.registerFd(fd); + + EXPECT_TRUE(waitForIrqAsserted(level, &source, loop, 5000)); + // Console descriptors nobody drains here must not survive into the wait. + EXPECT_EQ(0, loop.registeredCount()); + close(fd); +} + +TEST(CadTimeoutMillis, CoversTheScanAtRealisticSymbolTimes) { + // 8 symbol times (twice RadioLib's 4-symbol scan) plus 20 ms fixed slack. + EXPECT_EQ(52u, cadTimeoutMillis(4096)); // SF8 / 62.5 kHz + EXPECT_EQ(85u, cadTimeoutMillis(8192)); // SF11 / 250 kHz + EXPECT_EQ(544u, cadTimeoutMillis(65536)); // SF12 / 62.5 kHz +} + +TEST(CadTimeoutMillis, KeepsTheFixedSlackAtDegenerateSymbolTimes) { + EXPECT_EQ(20u, cadTimeoutMillis(0)); + EXPECT_EQ(20u, cadTimeoutMillis(1)); +} + +TEST(CadTimeoutMillis, GrowsWithSymbolTime) { + // Each SF step doubles the symbol time, so the bound must not saturate. + uint32_t prev = cadTimeoutMillis(1024); + for (uint32_t tsym = 2048; tsym <= 524288; tsym *= 2) { + uint32_t next = cadTimeoutMillis(tsym); + EXPECT_GT(next, prev); + prev = next; + } + // SF12 at 7.8 kHz, the slowest setting RadioLib will accept, must not + // overflow into a nonsense (tiny) bound. + EXPECT_GT(cadTimeoutMillis(525128), 4000u); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/linux/EventGPIOPin.cpp b/variants/linux/EventGPIOPin.cpp new file mode 100644 index 0000000000..9eea1c13ee --- /dev/null +++ b/variants/linux/EventGPIOPin.cpp @@ -0,0 +1,462 @@ +#ifdef ARDULINUX_HARDWARE + +#include "EventGPIOPin.h" + +#include "AppInfo.h" +#include "logging.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define consumer ardulinuxAppName + +// --------------------------------------------------------------------------- +// Chip resolution. +// +// Copied verbatim from ardulinux's cores/ardulinux/linux/gpio/LinuxGPIOPin.cpp +// (v0.2.2). Those helpers are file-static there and LinuxGPIOPin's chip/line +// members are private, so neither reuse nor subclassing is possible without +// forking ardulinux. Keep this copy byte-identical so an upstream fix can be +// re-synced by diffing against that file. +// --------------------------------------------------------------------------- + +static bool chip_is_gpiochip_device(const char *path) { + char *realname, *sysfsp, devpath[64]; + struct stat statbuf; + bool ret = false; + int rv; + + rv = lstat(path, &statbuf); + if (rv) + goto out; + + /* + * Is it a symbolic link? We have to resolve it before checking + * the rest. + */ + realname = S_ISLNK(statbuf.st_mode) ? realpath(path, NULL) : + strdup(path); + if (realname == NULL) + goto out; + + rv = stat(realname, &statbuf); + if (rv) + goto out_free_realname; + + /* Is it a character device? */ + if (!S_ISCHR(statbuf.st_mode)) { + errno = ENOTTY; + goto out_free_realname; + } + + /* Is the device associated with the GPIO subsystem? */ + snprintf(devpath, sizeof(devpath), "/sys/dev/char/%u:%u/subsystem", + major(statbuf.st_rdev), minor(statbuf.st_rdev)); + + sysfsp = realpath(devpath, NULL); + if (!sysfsp) + goto out_free_realname; + + /* + * In glibc, if any of the underlying readlink() calls fail (which is + * perfectly normal when resolving paths), errno is not cleared. + */ + errno = 0; + + if (strcmp(sysfsp, "/sys/bus/gpio") != 0) { + /* This is a character device but not the one we're after. */ + errno = ENODEV; + goto out_free_sysfsp; + } + + ret = true; + +out_free_sysfsp: + free(sysfsp); +out_free_realname: + free(realname); +out: + errno = 0; + return ret; +} + +static int chip_dir_filter(const struct dirent *entry) +{ + bool is_chip; + char *path; + int ret; + + ret = asprintf(&path, "/dev/%s", entry->d_name); + if (ret < 0) + return 0; + + is_chip = chip_is_gpiochip_device(path); + free(path); + return !!is_chip; +} + +static struct gpiod_chip *chip_open_by_name(const char *name) +{ + struct gpiod_chip *chip; + char *path; + int ret; + + ret = asprintf(&path, "/dev/%s", name); + if (ret < 0) + return NULL; + + chip = gpiod_chip_open(path); + free(path); + + return chip; +} + +// Open the gpiod chip whose label matches `chipLabel`. First tries +// "/dev/" as a device basename (covers the gpiochip0 case); +// falls back to scanning /dev/ and comparing each chip's reported label +// (covers passing a kernel label like "pinctrl-rp1" or "pinctrl-bcm2835"). +// Caller owns the returned chip; returns NULL if no chip matches. +static struct gpiod_chip *find_chip_by_label(const char *chipLabel) +{ + std::string path = "/dev/"; + path += chipLabel; + if (access(path.c_str(), R_OK) == 0) + return chip_open_by_name(chipLabel); + + struct dirent **entries; + int num_chips = scandir("/dev/", &entries, chip_dir_filter, alphasort); + if (num_chips <= 0) + return NULL; + + struct gpiod_chip *match = NULL; + for (int i = 0; i < num_chips; i++) { + if (!match) { + struct gpiod_chip *c = chip_open_by_name(entries[i]->d_name); + if (c) { +#if EVGPIO_GPIOD_V == 2 + struct gpiod_chip_info *info = gpiod_chip_get_info(c); + const char *label = info ? gpiod_chip_info_get_label(info) : NULL; + bool hit = label && strcmp(label, chipLabel) == 0; + if (info) gpiod_chip_info_free(info); +#else + const char *label = gpiod_chip_label(c); + bool hit = label && strcmp(label, chipLabel) == 0; +#endif + if (hit) { + match = c; + log(SysGPIO, LogDebug, + "find_chip_by_label(%s): scan matched %s", + chipLabel, entries[i]->d_name); + } else + gpiod_chip_close(c); + } + } + free(entries[i]); + } + free(entries); + return match; +} + +// --------------------------------------------------------------------------- +// EventGPIOPin +// --------------------------------------------------------------------------- + +EventGPIOPin::EventGPIOPin(pin_size_t n, const char* chipLabel, int lineOffset, + const char* pinName) + : GPIOPin(n, pinName) { + _offset = (unsigned int)lineOffset; + + _chip = find_chip_by_label(chipLabel); + if (!_chip) + throw std::invalid_argument("GPIO chip not found"); + +#if EVGPIO_GPIOD_V == 1 + _line = gpiod_chip_get_line(_chip, lineOffset); + if (!_line) { + releaseResources(); + throw std::invalid_argument("GPIO line not found"); + } +#endif + + // On v2, requestWithEdges() lazily allocates _evbuf and fails if that + // allocation fails -- see the comment there for why that (rather than a + // check here) is what makes _edge_ok a reliable invariant. + _edge_ok = requestWithEdges(INPUT); + if (!_edge_ok) { + log(SysGPIO, LogError, + "EventGPIOPin(%s): edge detection unavailable (%s); " + "falling back to timeout polling", + getName(), strerror(_last_errno)); + if (!requestPlainInput(INPUT)) { + releaseResources(); + throw std::invalid_argument("cannot request GPIO line"); + } + } +} + +EventGPIOPin::~EventGPIOPin() { + releaseResources(); +} + +void EventGPIOPin::releaseResources() { +#if EVGPIO_GPIOD_V == 2 + if (_line) { gpiod_line_request_release(_line); _line = NULL; } + if (_evbuf) { gpiod_edge_event_buffer_free(_evbuf); _evbuf = NULL; } +#else + if (_line) gpiod_line_release(_line); + _line = NULL; +#endif + if (_chip) { gpiod_chip_close(_chip); _chip = NULL; } +} + +// --------------------------------------------------------------------------- +// Line (re)configuration. +// +// On v2 the request*() helpers below share everything except the +// gpiod_line_settings they build: wrap the settings in a line_config, then +// either request the line (first call, _line still NULL) or reconfigure it +// in place (every later call, e.g. from setPinMode()). applySettings() holds +// that shared tail so each helper is just its settings calls plus one call +// here. v1 has no equivalent config object -- each mode is a distinct +// gpiod_line_request_*() call -- so its branches stay separate below. +// --------------------------------------------------------------------------- + +#if EVGPIO_GPIOD_V == 2 +bool EventGPIOPin::applySettings(struct gpiod_line_settings* settings) { + if (!settings) return false; + + struct gpiod_line_config* cfg = gpiod_line_config_new(); + if (!cfg) { + _last_errno = errno; + gpiod_line_settings_free(settings); + return false; + } + gpiod_line_config_add_line_settings(cfg, &_offset, 1, settings); + + int rv; + if (_line == NULL) { + struct gpiod_request_config* rc = gpiod_request_config_new(); + gpiod_request_config_set_consumer(rc, consumer); + _line = gpiod_chip_request_lines(_chip, rc, cfg); + // Capture errno immediately: gpiod_request_config_free() below and the + // frees at the end of this function are not guaranteed to preserve it. + _last_errno = errno; + gpiod_request_config_free(rc); + rv = (_line != NULL) ? 0 : -1; + } else { + // Reconfigure replaces the config wholesale, which is exactly why edge + // detection has to be restated here on every mode change. + rv = gpiod_line_request_reconfigure_lines(_line, cfg); + _last_errno = errno; // capture before the frees below can clobber it + } + + gpiod_line_config_free(cfg); + gpiod_line_settings_free(settings); + return rv == 0; +} +#endif + +bool EventGPIOPin::requestInput(PinMode m, bool with_edges) { +#if EVGPIO_GPIOD_V == 1 + // The flagless entry points libgpiod v1 offers -- gpiod_line_request_input() + // and gpiod_line_request_rising_edge_events() -- are defined as their _flags + // counterparts called with 0, so passing 0 here covers plain INPUT exactly. + int flags = 0; + if (m == INPUT_PULLUP) flags = GPIOD_LINE_REQUEST_FLAG_BIAS_PULL_UP; + else if (m == INPUT_PULLDOWN) flags = GPIOD_LINE_REQUEST_FLAG_BIAS_PULL_DOWN; + + int rv = with_edges + ? gpiod_line_request_rising_edge_events_flags(_line, consumer, flags) + : gpiod_line_request_input_flags(_line, consumer, flags); + _last_errno = errno; + return rv == 0; +#else + // The edge-detecting path is the only place _edge_ok is ever set true (see + // both call sites), so guaranteeing _evbuf here -- and only here -- is what + // makes "_edge_ok implies a usable _evbuf" an actual invariant rather than a + // hopeful comment: eventFd() and drainEvents() can then both trust _edge_ok + // alone, with no separate _evbuf check of their own to fall out of sync. + // Lazy (rather than always allocating in the constructor) because this is + // the only path that needs it, and it costs nothing to retry the allocation + // here if a prior attempt failed. + if (with_edges && !_evbuf) { + _evbuf = gpiod_edge_event_buffer_new(16); + if (!_evbuf) { + _last_errno = errno; + log(SysGPIO, LogError, + "EventGPIOPin(%s): edge-event buffer allocation failed", + getName()); + return false; + } + } + + struct gpiod_line_settings* settings = gpiod_line_settings_new(); + if (!settings) { _last_errno = errno; return false; } + gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT); + if (with_edges) + gpiod_line_settings_set_edge_detection(settings, GPIOD_LINE_EDGE_RISING); + if (m == INPUT_PULLUP) + gpiod_line_settings_set_bias(settings, GPIOD_LINE_BIAS_PULL_UP); + else if (m == INPUT_PULLDOWN) + gpiod_line_settings_set_bias(settings, GPIOD_LINE_BIAS_PULL_DOWN); + + return applySettings(settings); +#endif +} + +bool EventGPIOPin::requestOutput(PinStatus initial) { +#if EVGPIO_GPIOD_V == 1 + int rv = gpiod_line_request_output(_line, consumer, initial); + _last_errno = errno; + return rv == 0; +#else + struct gpiod_line_settings* settings = gpiod_line_settings_new(); + if (!settings) { _last_errno = errno; return false; } + gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT); + gpiod_line_settings_set_output_value(settings, (gpiod_line_value)initial); + + return applySettings(settings); +#endif +} + +PinStatus EventGPIOPin::readPinHardware() { +#if EVGPIO_GPIOD_V == 1 + // Valid on an event-requested line: libgpiod v1's get-value path handles + // LINE_REQUESTED_EVENTS by issuing the values ioctl on the event fd. + int res = gpiod_line_get_value(_line); +#else + int res = gpiod_line_request_get_value(_line, _offset); +#endif + if (res < 0) { + // An unrequested line (e.g. both requestWithEdges() and + // requestPlainInput() failed on a mode change) reads permanently LOW + // here with no other symptom -- silently stops all packet RX. This is + // called every event-loop iteration, so latch rather than flood: log the + // first occurrence only. + if (!_read_warned) { + log(SysGPIO, LogError, + "EventGPIOPin(%s): read failed (%s); reading LOW until this is " + "resolved (further occurrences suppressed)", + getName(), strerror(errno)); + _read_warned = true; + } + return LOW; + } + return (PinStatus)res; +} + +void EventGPIOPin::writePin(PinStatus s) { + if (GPIOPin::getPinMode() != OUTPUT) + setPinMode(OUTPUT); + GPIOPin::writePin(s); // update cached status + +#if EVGPIO_GPIOD_V == 1 + gpiod_line_set_value(_line, s); +#else + gpiod_line_request_set_value(_line, _offset, (gpiod_line_value)s); +#endif +} + +void EventGPIOPin::setPinMode(PinMode m) { + GPIOPin::setPinMode(m); // update cached mode + log + + if (m == OUTPUT) { + // Should never happen for DIO1. An output line has no edges to report, so + // say so rather than silently keeping a stale descriptor. + if (_edge_ok) { + log(SysGPIO, LogError, + "EventGPIOPin(%s): OUTPUT requested, edge detection disabled", + getName()); + _edge_ok = false; + } + // readPin() returns the cached status without touching hardware: mode is + // already OUTPUT (GPIOPin::setPinMode(m) above already updated it), so + // refreshState() short-circuits and skips readPinHardware(). Must be read + // *before* the v1 release below -- reading after release hits EPERM, + // which readPinHardware() maps to LOW, silently discarding the pin's + // prior level on every mode change. Mirrors upstream LinuxGPIOPin.cpp's + // gpiod_line_request_output(line, consumer, readPin()). + PinStatus initial = readPin(); +#if EVGPIO_GPIOD_V == 1 + gpiod_line_release(_line); +#endif + if (!requestOutput(initial)) { + log(SysGPIO, LogError, + "EventGPIOPin(%s): failed to request line as OUTPUT (%s)", + getName(), strerror(_last_errno)); + } + return; + } + + // INPUT / INPUT_PULLUP / INPUT_PULLDOWN. + // + // RadioLib calls pinMode(irq, INPUT) from SX126x::begin() *after* this pin is + // bound. v1 cannot reconfigure in place, and v2's reconfigure replaces the + // config wholesale, so edge detection must be restated here or every wake-up + // silently degrades to the poll timeout. +#if EVGPIO_GPIOD_V == 1 + gpiod_line_release(_line); // v1 cannot reconfigure in place +#endif + + _edge_ok = requestWithEdges(m); + if (!_edge_ok) { + log(SysGPIO, LogError, + "EventGPIOPin(%s): edge detection lost on mode change (%s); " + "falling back to timeout polling", + getName(), strerror(_last_errno)); + if (!requestPlainInput(m)) { + // Both the edge-detecting and plain-input requests failed: the line is + // now unrequested. readPinHardware() maps that to a permanent LOW, + // GPIOPin::callISR() never fires, and packets are silently dropped + // rather than merely delayed -- this must not be quiet. + log(SysGPIO, LogError, + "EventGPIOPin(%s): failed to request line as plain INPUT (%s); " + "line is unrequested, reads will return LOW until the next " + "setPinMode() call", + getName(), strerror(_last_errno)); + } + } +} + +int EventGPIOPin::eventFd() const { + if (!_edge_ok || _line == NULL) return -1; +#if EVGPIO_GPIOD_V == 1 + return gpiod_line_event_get_fd(_line); +#else + return gpiod_line_request_get_fd(_line); +#endif +} + +void EventGPIOPin::drainEvents() { + // _edge_ok is the single source of truth eventFd() also uses. It can only + // become true via requestWithEdges(), which on v2 lazily allocates _evbuf + // and returns false if that allocation fails -- so _edge_ok true implies a + // non-NULL _evbuf here too, not just at eventFd(). No separate _evbuf + // check needed; see requestWithEdges() for where that invariant is made. + if (!_edge_ok || _line == NULL) return; + +#if EVGPIO_GPIOD_V == 1 + // Zero timeout keeps this non-blocking: gpiod_line_event_read() on its own + // would block once the queue empties. + struct timespec zero = {0, 0}; + struct gpiod_line_event ev; + while (gpiod_line_event_wait(_line, &zero) == 1) { + if (gpiod_line_event_read(_line, &ev) != 0) break; + } +#else + while (gpiod_line_request_wait_edge_events(_line, 0) == 1) { + if (gpiod_line_request_read_edge_events(_line, _evbuf, 16) <= 0) break; + } +#endif +} + +#undef consumer + +#endif // ARDULINUX_HARDWARE diff --git a/variants/linux/EventGPIOPin.h b/variants/linux/EventGPIOPin.h new file mode 100644 index 0000000000..bbc1b507c3 --- /dev/null +++ b/variants/linux/EventGPIOPin.h @@ -0,0 +1,109 @@ +#pragma once + +#ifdef ARDULINUX_HARDWARE + +#include "Arduino.h" +#include "ArduLinuxGPIO.h" +#include "LinuxEventSource.h" + +#include + +// libgpiod major-version detection, mirroring ardulinux's LinuxGPIOPin.h. +// gpiod v1 defines GPIOD_LINE_BULK_MAX_LINES; v2 does not. +// +// Deliberately NOT named GPIOD_V: ardulinux's LinuxGPIOPin.h defines that +// symbol along with macro aliases (gpiod_line -> gpiod_line_request, etc.) +// that would collide if both headers reach one translation unit. +#ifndef GPIOD_LINE_BULK_MAX_LINES + #define EVGPIO_GPIOD_V 2 +#else + #define EVGPIO_GPIOD_V 1 +#endif + +#if EVGPIO_GPIOD_V == 2 + typedef struct gpiod_line_request evgpio_line_t; +#else + typedef struct gpiod_line evgpio_line_t; +#endif + +// An ArduLinux GPIO pin that additionally exposes a pollable edge-event +// descriptor, so the main loop can block instead of spinning. +// +// Used only for the LoRa DIO1/IRQ line. Every other pin keeps using ardulinux's +// LinuxGPIOPin: they are outputs or synchronous reads with no events to wait on. +// +// The event descriptor is only a wake-up hint. Packet correctness still comes +// from ardulinux's gpioIdle() level-read-and-fire-ISR path, which is untouched, +// so losing edge detection degrades latency rather than dropping packets. +class EventGPIOPin : public GPIOPin, public LinuxEventSource { +public: + // Throws std::invalid_argument if the chip or line cannot be acquired at all. + // Failing to get *edge detection* specifically is not an error: the pin still + // works, hasEdgeDetection() returns false, and eventFd() returns -1. + EventGPIOPin(pin_size_t n, const char* chipLabel, int lineOffset, + const char* pinName); + ~EventGPIOPin() override; + + bool hasEdgeDetection() const { return _edge_ok; } + + // LinuxEventSource + int eventFd() const override; + void drainEvents() override; + +protected: + // GPIOPin + PinStatus readPinHardware() override; + void writePin(PinStatus s) override; + void setPinMode(PinMode m) override; + +private: + // Request the line as an input, with or without rising-edge detection. The + // two differ by one setting on v2 and by which gpiod_line_request_* family is + // called on v1, so they share one body; the named wrappers below keep the + // call sites (and the invariants documented against them) reading the same. + bool requestInput(PinMode m, bool with_edges); + bool requestWithEdges(PinMode m) { return requestInput(m, true); } + bool requestPlainInput(PinMode m) { return requestInput(m, false); } + bool requestOutput(PinStatus initial); + + // Release the line/chip/event-buffer (whichever are currently held) and + // null them out. Shared by the destructor and by the constructor's failure + // paths: a constructor that throws never runs the destructor, so every + // throw after a partial acquisition must call this itself or leak. + void releaseResources(); + +#if EVGPIO_GPIOD_V == 2 + // Shared tail of requestWithEdges/requestPlainInput/requestOutput: wraps + // `settings` in a line_config, requests the line (first call) or + // reconfigures it (subsequent calls), and frees both `settings` and the + // config it builds. Takes ownership of `settings` unconditionally, on + // every return path. + bool applySettings(struct gpiod_line_settings* settings); +#endif + + evgpio_line_t* _line = NULL; + struct gpiod_chip* _chip = NULL; + unsigned int _offset = 0; + bool _edge_ok = false; + + // Latches the first readPinHardware() failure so the log is not flooded from + // a path called every event-loop iteration. Per-instance rather than a + // function-local static: a static would let one pin's failure suppress + // another's first report entirely. + bool _read_warned = false; +#if EVGPIO_GPIOD_V == 2 + struct gpiod_edge_event_buffer* _evbuf = NULL; +#endif + + // errno from the most recent failing libgpiod call in + // requestWithEdges()/requestPlainInput()/requestOutput() (and, on v2, + // applySettings()). Callers log strerror(_last_errno) rather than + // strerror(errno): on v2 the request*() helpers run through applySettings(), + // whose cleanup (gpiod_line_config_free()/gpiod_line_settings_free()) happens + // between the failing call and the log site and can clobber errno first. + // Captured immediately after each libgpiod call, at the point closest to + // where it can still be trusted. + int _last_errno = 0; +}; + +#endif // ARDULINUX_HARDWARE diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index b0fb94ad18..d8e49a6bbf 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -4,11 +4,17 @@ #include #include #include +#include #include #ifdef ARDULINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" #endif +#ifdef ARDULINUX_HARDWARE +#include "EventGPIOPin.h" +#endif #include "LinuxBoard.h" +#include "LinuxEventLoop.h" +#include "LinuxRadioWait.h" #include "AppInfo.h" // Still hardcoded -- see "Known Gaps" in variants/linux/README.md -- but named, @@ -45,6 +51,41 @@ int initGPIOPin(uint8_t pinNum, const std::string gpioChipName, uint8_t line) #endif } +// Bind the LoRa IRQ line as an EventGPIOPin so the main loop can block on its +// edge-event descriptor instead of spinning. Returns 0 on success, 1 on +// failure (same convention as initGPIOPin). +// +// Falling back to a plain LinuxGPIOPin is not needed here: EventGPIOPin only +// throws when the line cannot be acquired at all, and it degrades internally +// when edge detection specifically is unavailable. +static int initEventGPIOPin(LinuxEventSource** out, uint8_t pinNum, + const std::string gpioChipName, uint8_t line) { +#ifdef ARDULINUX_HARDWARE + char gpio_name[32]; + snprintf(gpio_name, sizeof(gpio_name), "GPIO%d", pinNum); + + try { + EventGPIOPin* pin = new EventGPIOPin(pinNum, gpioChipName.c_str(), line, gpio_name); + pin->setSilent(); + gpioBind(pin); + *out = pin; + printf("LoRa IRQ pin %d bound with edge detection: %s\n", + (int)pinNum, pin->hasEdgeDetection() ? "yes" : "NO (polling fallback)"); + return 0; + } catch (const std::exception& e) { + printf("ERROR: cannot claim IRQ GPIO line %d on %s for pin %d: %s\n", + (int)line, gpioChipName.c_str(), (int)pinNum, e.what()); + return 1; + } catch (...) { + printf("ERROR: cannot claim IRQ GPIO line %d on %s for pin %d (unknown exception)\n", + (int)line, gpioChipName.c_str(), (int)pinNum); + return 1; + } +#else + return 0; +#endif +} + void ardulinuxSetup() { } @@ -124,7 +165,8 @@ void LinuxBoard::begin() { failures += initGPIOPin(config.lora_busy_pin, config.lora_gpiochip, config.lora_busy_pin); } if (config.lora_irq_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_irq_pin, config.lora_gpiochip, config.lora_irq_pin); + failures += initEventGPIOPin(&irq_event_source, config.lora_irq_pin, + config.lora_gpiochip, config.lora_irq_pin); } if (config.lora_reset_pin != RADIOLIB_NC) { failures += initGPIOPin(config.lora_reset_pin, config.lora_gpiochip, config.lora_reset_pin); @@ -155,6 +197,83 @@ void LinuxBoard::reboot() { ::reboot(); } +void LinuxBoard::idleUntilEvent(uint32_t max_wait_ms) { + LinuxEventSource* src = irqEventSource(); + + // Without edge detection nothing can wake us, so the caller's ceiling would + // be pure latency: poll tightly instead. Same tradeoff (and same value) as + // the delay(1) fallback in ESP32Board::sleep(). + const bool have_events = (src != NULL && src->eventFd() >= 0); + // poll(2) (which EventLoop.wait() below calls into) treats a negative + // timeout as "block forever". max_wait_ms > INT_MAX would cast negative and + // silently turn a bounded wait into an infinite one, so clamp instead. + const uint32_t wait_ms = max_wait_ms > (uint32_t) INT_MAX ? (uint32_t) INT_MAX : max_wait_ms; + const int timeout_ms = have_events ? (int) wait_ms : 1; + + EventLoop.reset(); + EventLoop.setEventSource(src); + + // Only descriptors that loop() will actually drain this iteration may be + // registered here. POLLIN is level-triggered, so a registered descriptor + // that nothing reads stays readable forever and turns this wait back into + // the busy loop it exists to remove. A byte source that is only drained + // conditionally (a GPS stream while GPS is switched off, say) is better + // served off the poll timeout than registered. + + // Refresh the cached IRQ level immediately before blocking. Packet + // correctness does not come from the edge-event descriptor above; it comes + // from ArduLinux's gpioIdle(), which fires RadioLib's ISR on a LOW->HIGH + // transition against a *cached* previous level. Nothing else in the + // MeshCore call path refreshes that cache (no delay() calls in + // Dispatcher.cpp/Mesh.cpp/MyMesh.cpp, and RadioLib's own + // digitalRead(getIrq()) calls live only in blocking paths MeshCore doesn't + // use), so the cache is stale from the moment gpioIdle() handles an + // interrupt until the next iteration's gpioIdle() call. Without this line + // the safe timeout ceiling would be bounded by packet airtime -- past that, + // DIO1 stays latched HIGH with no further rising edge to recover on, and RX + // stops silently rather than merely adding latency. This is what lets the + // caller choose max_wait_ms freely, and it is the obligation + // MainBoard::idleUntilEvent() documents for every implementer. + // Cost is one ioctl per wake; it is latency-safe, because if the line is + // already HIGH here an edge event is already queued and the wait below + // returns immediately instead of blocking. Do not remove this as + // "redundant" with gpioIdle() -- it is the only thing keeping a longer + // timeout safe. + if (config.lora_irq_pin != RADIOLIB_NC) digitalRead(config.lora_irq_pin); + + EventLoop.wait(timeout_ms); +} + +namespace { + +// Samples the LoRa IRQ line for waitForIrqAsserted(). +// +// digitalRead() rather than a bare level read, and that is deliberate: in +// ardulinux it runs GPIOPin::readPin() -> refreshState(), which reads the +// hardware, updates the cached level and fires the attached ISR on the +// configured edge. Sampling the line here therefore also keeps that cache +// coherent while the main loop is parked inside a scan, for exactly the reason +// idleUntilEvent() reads the pin before blocking. +class RadioIrqLevel : public LinuxIrqLevel { +public: + explicit RadioIrqLevel(uint32_t pin) : _pin(pin) { } + bool irqAsserted() override { return digitalRead(_pin) == HIGH; } + +private: + uint32_t _pin; +}; + +} // namespace + +bool LinuxBoard::waitForRadioIrq(uint32_t timeout_ms) { + // Nothing to wait on. Callers read the operation's result over SPI anyway, so + // this costs them the wait, not the answer. + if (config.lora_irq_pin == RADIOLIB_NC) return false; + + RadioIrqLevel level(config.lora_irq_pin); + return waitForIrqAsserted(level, irqEventSource(), EventLoop, timeout_ms); +} + // Trim whitespace from both ends, returning the trimmed string. // // Returns rather than trimming in place because the leading trim cannot be done diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index cfe5cffd37..15b71e051d 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -10,6 +10,7 @@ #include #include #include +#include "LinuxEventSource.h" class LinuxConfig { public: @@ -105,6 +106,30 @@ class LinuxBoard : public mesh::MainBoard { // Re-exec this process image rather than exit. Defined in LinuxBoard.cpp. void reboot() override; + // Block on the LoRa IRQ edge descriptor (plus any other descriptor that will + // actually be drained this iteration) instead of spinning. Defined in + // LinuxBoard.cpp; see variants/linux/LinuxEventLoop.h for the poll wrapper. + void idleUntilEvent(uint32_t max_wait_ms) override; + + // Sleep until the LoRa IRQ line goes high or timeout_ms elapses, returning + // true if it went high. The narrow sibling of idleUntilEvent(): same event + // source, same fallback, but it watches only the radio and is driven by a + // caller that has just armed a specific operation and needs its completion. + // + // Used for hardware CAD, where RadioLib's own scanChannel() would otherwise + // busy-spin on the line with no deadline. Returns false immediately when no + // IRQ pin is configured; the caller reads the result over SPI either way. + bool waitForRadioIrq(uint32_t timeout_ms); + + // Wake-up source for the Linux event loop (the LoRa IRQ line), or NULL when + // edge detection is unavailable. Typed as the abstract interface so this + // header stays free of any libgpiod dependency. + LinuxEventSource* irqEventSource() const { return irq_event_source; } + +protected: + LinuxEventSource* irq_event_source = nullptr; + +public: LinuxConfig config; }; diff --git a/variants/linux/LinuxEventLoop.cpp b/variants/linux/LinuxEventLoop.cpp new file mode 100644 index 0000000000..6d5cf82f01 --- /dev/null +++ b/variants/linux/LinuxEventLoop.cpp @@ -0,0 +1,85 @@ +#include "LinuxEventLoop.h" + +#include +#include +#include + +// Cool-off applied to any wait that would otherwise return instantly without a +// readable descriptor, so a stale or hung-up descriptor cannot reinstate the +// busy loop. +#define EVENT_LOOP_ERROR_BACKOFF_US 1000 + +const int LinuxEventLoop::MAX_FDS; +LinuxEventLoop EventLoop; + +void LinuxEventLoop::reset() { + _nfds = 0; + _source = nullptr; +} + +void LinuxEventLoop::registerFd(int fd) { + if (fd < 0) return; + if (_nfds >= MAX_FDS) return; + for (int i = 0; i < _nfds; i++) { + if (_fds[i] == fd) return; // already watching it + } + _fds[_nfds++] = fd; +} + +void LinuxEventLoop::setEventSource(LinuxEventSource* source) { + _source = source; +} + +int LinuxEventLoop::wait(int timeout_ms) { + struct pollfd pfds[MAX_FDS + 1]; + int n = 0; + int source_idx = -1; + + if (_source != nullptr) { + int fd = _source->eventFd(); + if (fd >= 0) { + source_idx = n; + pfds[n].fd = fd; + pfds[n].events = POLLIN; + pfds[n].revents = 0; + n++; + } + } + + for (int i = 0; i < _nfds; i++) { + pfds[n].fd = _fds[i]; + pfds[n].events = POLLIN; + pfds[n].revents = 0; + n++; + } + + // poll() with zero descriptors is a portable plain sleep, which is exactly + // the behaviour we want when nothing is available to watch. + int rv = poll(n > 0 ? pfds : NULL, n, timeout_ms); + + if (rv < 0) { + if (errno == EINTR) return -1; // caller simply loops again + usleep(EVENT_LOOP_ERROR_BACKOFF_US); + return 0; + } + if (rv == 0) return 0; // clean timeout + + // Count only genuinely readable descriptors. poll() also returns a positive + // count for POLLNVAL (stale descriptor) and POLLHUP (peer hung up), neither + // of which clears by itself — returning those to the caller unthrottled + // would spin. + int readable = 0; + for (int i = 0; i < n; i++) { + if ((pfds[i].revents & POLLIN) != 0) readable++; + } + + if (readable == 0) { + usleep(EVENT_LOOP_ERROR_BACKOFF_US); + return 0; + } + + if (source_idx >= 0 && (pfds[source_idx].revents & POLLIN) != 0) { + _source->drainEvents(); + } + return readable; +} diff --git a/variants/linux/LinuxEventLoop.h b/variants/linux/LinuxEventLoop.h new file mode 100644 index 0000000000..b435a36e26 --- /dev/null +++ b/variants/linux/LinuxEventLoop.h @@ -0,0 +1,51 @@ +#pragma once + +#include "LinuxEventSource.h" + +// Blocking wait for the ArduLinux main loop. +// +// ArduLinux's runtime spins `while (true) { gpioIdle(); loop(); }` with no +// sleep once real hardware is bound (its 100 ms delay is skipped when +// realHardware is true), which burns 100% of a core. Calling wait() at the end +// of loop() makes that outer loop run at the event rate instead. +// +// The descriptor set is rebuilt on every call because the console's connected +// client descriptor comes and goes as clients attach and detach. +class LinuxEventLoop { +public: + static const int MAX_FDS = 8; + + // Drop all registered descriptors and the event source. + void reset(); + + // Watch a descriptor for readability. Negative descriptors (a closed GPS + // device, an unconnected console client) are ignored, as are duplicates and + // anything beyond MAX_FDS. + void registerFd(int fd); + + // Set the wake-up source, or NULL when none is available. + void setEventSource(LinuxEventSource* source); + + // Block until a watched descriptor is readable or timeout_ms elapses, then + // drain the event source if it was the one that fired. + // + // Returns the number of descriptors reporting POLLIN, or 0 if the wait timed + // out. Returns -1 only on EINTR, where returning immediately is correct + // because the caller loops again anyway. + // + // Conditions that would otherwise spin — a stale descriptor reporting + // POLLNVAL, a hung-up peer reporting POLLHUP, or a poll() failure other than + // EINTR — sleep 1 ms and report 0. poll() returns a positive count for + // POLLNVAL, so without this a single closed descriptor would turn the wait + // back into the busy loop this class exists to remove. + int wait(int timeout_ms); + + int registeredCount() const { return _nfds; } + +private: + int _fds[MAX_FDS]; + int _nfds = 0; + LinuxEventSource* _source = nullptr; +}; + +extern LinuxEventLoop EventLoop; diff --git a/variants/linux/LinuxEventSource.h b/variants/linux/LinuxEventSource.h new file mode 100644 index 0000000000..d427763900 --- /dev/null +++ b/variants/linux/LinuxEventSource.h @@ -0,0 +1,20 @@ +#pragma once + +// Abstract wake-up source for LinuxEventLoop. +// +// This interface exists so LinuxEventLoop can be compiled and unit-tested on a +// host with no libgpiod and no GPIO hardware: the loop depends only on this +// plus plain file descriptors. EventGPIOPin is the production implementation. +class LinuxEventSource { +public: + virtual ~LinuxEventSource() {} + + // Pollable descriptor that becomes readable when an event is pending, or -1 + // when this source has no working event descriptor. + virtual int eventFd() const = 0; + + // Consume every event queued on eventFd(). Must be called after poll() + // reports the descriptor readable: POLLIN is level-triggered, so an + // undrained descriptor makes every subsequent poll() return immediately. + virtual void drainEvents() = 0; +}; diff --git a/variants/linux/LinuxRadioWait.cpp b/variants/linux/LinuxRadioWait.cpp new file mode 100644 index 0000000000..860d6928ca --- /dev/null +++ b/variants/linux/LinuxRadioWait.cpp @@ -0,0 +1,69 @@ +#include "LinuxRadioWait.h" + +#include "LinuxEventLoop.h" +#include "LinuxEventSource.h" + +#include +#include + +// Slice length used when the event source has no usable edge descriptor. With +// nothing to block on, the level has to be re-read periodically; 1 ms matches +// LinuxBoard::idleUntilEvent()'s fallback, and the deadline still bounds the +// total wait. +#define IRQ_WAIT_FALLBACK_SLICE_MS 1 + +// Deliberately CLOCK_MONOTONIC rather than Arduino millis(). +// +// A monotonic clock is the natural one for a poll() deadline -- it cannot be +// dragged by settimeofday(), which LinuxRTCClock::setCurrentTime() calls +// whenever the mesh corrects the node's time. It also keeps this unit off the +// Arduino layer, which matters for more than tidiness: the native test build's +// Arduino.h mock freezes millis() at g_mock_millis, so a deadline computed from +// it would never be reached and the wait below would not terminate under test. +static uint64_t monotonicMillis() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)(ts.tv_nsec / 1000000); +} + +bool waitForIrqAsserted(LinuxIrqLevel& level, LinuxEventSource* src, + LinuxEventLoop& loop, uint32_t timeout_ms) { + loop.reset(); + loop.setEventSource(src); + + const bool have_events = (src != NULL && src->eventFd() >= 0); + const uint64_t deadline = monotonicMillis() + timeout_ms; + + for (;;) { + // Sampled before every wait, and once more before returning false, so a + // line that is already high on entry costs no poll() at all and a level + // that rises during the final slice is still seen. + if (level.irqAsserted()) return true; + + uint64_t now = monotonicMillis(); + if (now >= deadline) return false; + + // An edge arriving between the sample above and the poll() below is not + // lost: it leaves the descriptor readable, so the wait returns at once and + // the next iteration reads the raised line. + // + // A wake that turns out not to be our edge -- EINTR, or a drained event + // that did not correspond to a level change -- simply loops. That cannot + // spin: wait() drains the source it reports readable, and applies its own + // cool-off to the degenerate cases (POLLNVAL, POLLHUP, poll() failure) + // that would otherwise return instantly forever. + // + // Clamped rather than cast: timeout_ms is a uint32_t and poll() takes an + // int, so a caller asking for more than INT_MAX ms (~24.8 days) would hand + // poll() a negative timeout, which means "block forever" -- the unbreakable + // hang this whole function exists to make impossible. Nothing asks for that + // today; the clamp is here so that nothing can. + uint64_t remaining = deadline - now; + if (remaining > (uint64_t)INT_MAX) remaining = (uint64_t)INT_MAX; + loop.wait(have_events ? (int)remaining : IRQ_WAIT_FALLBACK_SLICE_MS); + } +} + +uint32_t cadTimeoutMillis(uint32_t symbol_micros) { + return (symbol_micros * 8) / 1000 + 20; +} diff --git a/variants/linux/LinuxRadioWait.h b/variants/linux/LinuxRadioWait.h new file mode 100644 index 0000000000..825646bd0c --- /dev/null +++ b/variants/linux/LinuxRadioWait.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +class LinuxEventLoop; +class LinuxEventSource; + +// Reads the current level of the line a wait is watching. +// +// Abstract for the same reason LinuxEventSource is: it keeps waitForIrqAsserted() +// free of any GPIO dependency, so this unit compiles and is unit-tested on a host +// with no libgpiod. LinuxBoard supplies the production implementation, backed by +// digitalRead() on the configured LoRa IRQ pin. +class LinuxIrqLevel { +public: + virtual ~LinuxIrqLevel() {} + + // True once the radio has raised its interrupt line. + virtual bool irqAsserted() = 0; +}; + +// Sleep until level.irqAsserted() reports true or timeout_ms elapses, whichever +// comes first. Returns true if the line asserted before the deadline. +// +// Never spins. Each iteration blocks in poll() on src's edge descriptor for the +// whole remaining time; where src has no usable descriptor it waits in 1 ms +// slices instead, the same fallback (and the same value) as +// LinuxBoard::idleUntilEvent(). +// +// This replaces the pattern RadioLib's blocking helpers use -- +// while(!hal->digitalRead(mod->getIrq())) { hal->yield(); } +// -- which burns a core for the duration and, having no deadline, converts a +// GPIO read that has started failing into an unbreakable hang of the caller. +// +// `loop` is reset and re-pointed at `src` on entry. No other descriptor is +// registered: nothing here would drain a console or GPS descriptor, and a +// registered-but-undrained descriptor stays POLLIN forever, which is precisely +// the busy loop LinuxEventLoop exists to remove. +bool waitForIrqAsserted(LinuxIrqLevel& level, LinuxEventSource* src, + LinuxEventLoop& loop, uint32_t timeout_ms); + +// How long to wait for a channel-activity-detection scan to raise DIO1, given +// the current symbol time in microseconds. +// +// RadioLib scans 4 symbols (RADIOLIB_SX126X_CAD_ON_4_SYMB, which is what +// SX126x::setCad() uses when handed RADIOLIB_SX126X_CAD_PARAM_DEFAULT). Allowing +// 8 symbol times gives twice the scan, and the fixed 20 ms covers SPI turnaround +// and Linux scheduler jitter. +// +// This is a bound on *failure*, not a budget for the normal case: a healthy scan +// returns the instant the line rises, typically in half this. Worked values: +// 52 ms at SF8/62.5 kHz, 85 ms at SF11/250 kHz, 544 ms at SF12/62.5 kHz. +uint32_t cadTimeoutMillis(uint32_t symbol_micros); diff --git a/variants/linux/README.md b/variants/linux/README.md index 65e1f767ae..47eca39759 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -272,9 +272,71 @@ sudo systemctl start meshcored > **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter, use the CLI (`set freq`, `set sf`, etc.) or reset prefs as above. +## Operation + +### Idle CPU usage + +`meshcored` blocks in `poll()` between events rather than spinning: expect **well +under 1% CPU at idle** with edge detection, and roughly **1–3%** in the polling +fallback. Startup logs which mode a bound LoRa IRQ pin ended up in: + +``` +LoRa IRQ pin 25 bound with edge detection: yes +``` + +`NO (polling fallback)` means the kernel or libgpiod on this device could not set +up edge events for that line, so the daemon uses a 1 ms poll timeout instead of +the normal 50 ms `IDLE_MAX_WAIT_MS`. Packet RX/TX is unaffected; it just costs +CPU. + +Edge detection can also be lost later in a run: + +``` +EventGPIOPin(GPIO25): edge detection lost on mode change (...); falling back to timeout polling +``` + +The daemon stays correct but degrades to timeout polling for the rest of the run. +This is not expected in normal operation — worth reporting, with the `(...)` +errno text and your libgpiod and kernel versions. + +### Channel Activity Detection + +Off by default. When enabled, the radio runs a hardware CAD scan immediately +before each transmit and defers if it detects a LoRa signal. The change takes +effect within 2 seconds, no restart needed: + +``` +set cad on # or: set cad off +get cad +``` + +CAD complements `int.thresh` rather than replacing it; either, both or neither +may be active: + +- **`int.thresh`** compares RSSI against the measured noise floor. It sees any + energy, including non-LoRa interference, but cannot see a signal below the + noise floor. +- **`cad`** correlates against the LoRa preamble, so it detects a real LoRa + transmission *below* the noise floor where RSSI is blind — but ignores non-LoRa + energy entirely. + +Enabling `int.thresh` alongside `cad` also reduces how often the scan runs: the +RSSI check is evaluated first, and a busy verdict there skips the scan. + +**Cost at high spreading factors.** A scan takes about four symbol times: roughly +20 ms at SF8/62.5 kHz, but around 265 ms at SF12/62.5 kHz. Transmit attempts +retry every 200 ms while the channel reads busy, so at SF12 a node holding a +queued packet on a contended channel spends over half that window inside a scan — +with the modem in standby, **not listening**. That is inherent to CAD-before-TX, +but worth knowing before enabling it on a high-SF preset. + ## Known Gaps / TODO - **Config path is hardcoded**, meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The data *path* is separate and configurable: it is the ArduLinux VFS root, set with `--fsdir`.) - **Only repeater firmware**, there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. - **Serial `erase` command is a no-op**, `formatFileSystem()` returns `false` on Linux, so the interactive serial `erase` command reports failure. To wipe the filesystem, use the `--erase` *startup* flag (or clear the VFS dir) instead, see step 5. - **No power management**, `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. +- **libgpiod v2 is compile-verified only.** `EventGPIOPin`'s v2 path (Debian + trixie and newer) builds cleanly in the trixie `build-docker.sh` container, but has never been + exercised at runtime against real hardware — all runtime verification to date is + on libgpiod v1 (bookworm). Treat it as unproven.