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 cbc83957d3..99ff227e4c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -224,6 +224,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 = @@ -232,6 +233,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 f63968dce3..508c04773a 100644 --- a/src/helpers/radiolib/LinuxSX1262Wrapper.h +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -2,6 +2,7 @@ #include "CustomSX1262Wrapper.h" #include "LinuxSX1262.h" +#include "LinuxRadioWait.h" #include "SX126xReset.h" // LinuxSX1262 is a CustomSX1262, so its wrapper is a CustomSX1262Wrapper. @@ -15,6 +16,15 @@ // instead of breaking the Linux build (pure virtual) or silently no-opping on // it alone (virtual with a default). class LinuxSX1262Wrapper : public CustomSX1262Wrapper { + // 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. The inherited members downcast it to // CustomSX1262, which is as far as they need to see; this names the // LinuxSX1262 downcast for the parts only the Linux subclass has. It is always @@ -22,9 +32,67 @@ class LinuxSX1262Wrapper : public CustomSX1262Wrapper { // 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) : CustomSX1262Wrapper(radio, board) { } + // Re-added only to size the CAD wait from the active SF/BW. Everything else + // the base class already does, so it does it -- this is not a reimplementation. + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + CustomSX1262Wrapper::setParams(freq, bw, sf, cr); + _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 line that never reports (dead, + // or never configured) costs latency and a log line, never a wrong answer, + // and never a hang. What it must not do is cut the wait short: the status + // register is only authoritative once the scan has had time to finish. + return r()->getChannelScanResult(); + } + // The one override that is not the inherited behaviour. Recalibration drops // DIO2-as-RF-switch, RX boosted gain and the 0x8B5 patch, and the inherited // version restores the first and third from SX126X_* build flags -- none of 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..85ebf1666c --- /dev/null +++ b/test/test_linux_event_loop/test_linux_event_loop.cpp @@ -0,0 +1,334 @@ +#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; } + + bool drainEvents() override { + drain_calls++; + // A source that cannot drain leaves its descriptor readable -- that is the + // whole reason the failure has to reach the caller -- so read nothing here. + if (!_drain_ok) return false; + unsigned char buf[64]; + while (::read(_fds[0], buf, sizeof buf) > 0) { } + return true; + } + + // Simulate a persistent read error on the event descriptor (EIO on a wedged + // GPIO controller, say). + void failDrains() { _drain_ok = false; } + + // 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; + bool _drain_ok = 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 -- which is where CI runs this + // native build, and the only platform the production code targets -- reports + // POLLHUP alone. macOS, where the same build is often run locally, 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 rather than hardcoding either one. + 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; + + // 100 µs, not something closer to the 1000 µs backoff: usleep() may return + // early on EINTR, and the only thing this test needs to discriminate is + // "slept at all" versus "returned instantly". A tighter bound would buy no + // extra discrimination and would flake under a signal. + EXPECT_GE(elapsed_us, 100); +} + +TEST(LinuxEventLoopWait, FailedDrainBacksOffInsteadOfReportingReadable) { + // Why drainEvents() returns bool. A source that cannot clear its descriptor + // (a persistent read error on the gpiod event fd) leaves it readable; if + // wait() reported the wake anyway, the caller would poll(), be woken at + // once, fail to drain again, and burn 100% of a core with nothing further in + // the log -- the exact busy loop this class exists to remove, and the one + // shape of it that is invisible from the outside. + LinuxEventLoop loop; + FakePipeSource source; + loop.reset(); + loop.setEventSource(&source); + source.failDrains(); + + source.signal(); + + struct timespec start, end; + clock_gettime(CLOCK_MONOTONIC, &start); + + // Not a readable count: a descriptor the caller cannot clear is not a wake. + EXPECT_EQ(0, loop.wait(0)); + + clock_gettime(CLOCK_MONOTONIC, &end); + long elapsed_us = (end.tv_sec - start.tv_sec) * 1000000 + + (end.tv_nsec - start.tv_nsec) / 1000; + + EXPECT_EQ(1, source.drain_calls); // it did try + EXPECT_GE(elapsed_us, 100); // and then backed off, as above + EXPECT_GT(source.pendingBytes(), 0); // descriptor still readable, as in the real failure +} + +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..106cc9a0cd --- /dev/null +++ b/test/test_linux_radio_wait/test_linux_radio_wait.cpp @@ -0,0 +1,393 @@ +#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; } + + bool drainEvents() override { + drain_calls++; + // A source that cannot drain leaves its descriptor readable, so read + // nothing on that path -- that is what makes the failure spin-shaped. + if (!_drain_ok) return false; + unsigned char buf[64]; + while (::read(_fds[0], buf, sizeof buf) > 0) { } + return true; + } + + // Simulate a persistent read error on the event descriptor. + void failDrains() { _drain_ok = false; } + + 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; + bool _drain_ok = 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, DoesNotSpinWhenTheSourceCannotDrain) { + // The sibling of the test above for the case where draining does not work: + // the descriptor is readable, stays readable, and drainEvents() says so. + // LinuxEventLoop::wait() has to back off for that exactly as it does for a + // stale descriptor, or the wait degenerates into the 100%-core loop this + // whole unit exists to remove -- silently, since a failed drain is not + // otherwise visible from here. + LinuxEventLoop loop; + FakePipeSource source; + FakeIrqLevel level; // never asserts + + source.failDrains(); + source.signal(); // readable, and nothing will clear it + + 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); + + // The discriminator. One level sample per iteration, so this counts the + // iterations: with the 1 ms backoff it is on the order of the timeout in + // milliseconds, and without it the loop would run as fast as poll() can + // return -- tens of thousands of times in these 40 ms. + EXPECT_GE(source.drain_calls, 1); + EXPECT_LT(level.calls, 1000); + EXPECT_GT(source.pendingBytes(), 0); // still undrained, as in the real failure +} + +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..fd60b4d358 --- /dev/null +++ b/variants/linux/EventGPIOPin.cpp @@ -0,0 +1,415 @@ +#ifdef ARDULINUX_HARDWARE + +#include "EventGPIOPin.h" + +#include "AppInfo.h" +#include "logging.h" + +#include +#include +#include +#include +#include +#include + +#define GPIO_CONSUMER ardulinuxAppName + +// --------------------------------------------------------------------------- +// Chip resolution. +// +// Turn the lora_gpiochip setting -- either a device name ("gpiochip0") or a +// kernel label ("pinctrl-rp1") -- into an open chip, using nothing but +// libgpiod's public API. How much of that libgpiod does for us differs by +// major version: v1 exports gpiod_chip_open_lookup(), which already tries a +// name, a label, a path and a bare number in turn; v2 dropped it and exports +// gpiod_is_gpiochip_device() instead, leaving the label scan to the caller -- +// so on v2 we walk /dev ourselves, using that predicate as the filter. +// --------------------------------------------------------------------------- + +#if EVGPIO_GPIOD_V == 2 + +// scandir() filter: keep exactly the /dev entries libgpiod itself recognises +// as GPIO chip character devices (symlinks included). +static int chip_dir_filter(const struct dirent* entry) { + std::string path = "/dev/"; + path += entry->d_name; + return gpiod_is_gpiochip_device(path.c_str()) ? 1 : 0; +} + +// Open "/dev/". NULL if it is absent or not a GPIO chip. +static struct gpiod_chip* open_chip_dev(const char* name) { + std::string path = "/dev/"; + path += name; + return gpiod_chip_open(path.c_str()); +} + +// Does an already-open chip report `chipLabel` as its kernel label? +static bool chip_label_matches(struct gpiod_chip* chip, const char* chipLabel) { + struct gpiod_chip_info* info = gpiod_chip_get_info(chip); + if (!info) return false; + const char* label = gpiod_chip_info_get_label(info); + bool hit = label && strcmp(label, chipLabel) == 0; + gpiod_chip_info_free(info); + return hit; +} + +#endif // EVGPIO_GPIOD_V == 2 + +// Open the chip named by `chipLabel`. The caller owns the returned chip; NULL +// means nothing matched, which is what makes the constructor throw. +static struct gpiod_chip* find_chip_by_label(const char* chipLabel) { +#if EVGPIO_GPIOD_V == 1 + struct gpiod_chip* chip = gpiod_chip_open_lookup(chipLabel); + if (chip) + log(SysGPIO, LogDebug, "find_chip_by_label(%s): lookup matched %s", + chipLabel, gpiod_chip_name(chip)); + return chip; +#else + // Device name first ("/dev/gpiochip0"), which is both the default and the + // common case, so the scan below never runs for it. + struct gpiod_chip* chip = open_chip_dev(chipLabel); + if (chip) return chip; + + // Otherwise treat it as a kernel label and compare against every GPIO chip + // in /dev. + struct dirent** entries; + int num_chips = scandir("/dev/", &entries, chip_dir_filter, alphasort); + // Only a negative return leaves `entries` unset; a zero-match scan still + // hands back an allocated (empty) array, so fall through to the free below. + if (num_chips < 0) return NULL; + + struct gpiod_chip* match = NULL; + for (int i = 0; i < num_chips; i++) { + // Keep looping even once matched: every entry still has to be freed. + if (!match) { + struct gpiod_chip* c = open_chip_dev(entries[i]->d_name); + if (c) { + if (chip_label_matches(c, chipLabel)) { + 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; +#endif +} + +// --------------------------------------------------------------------------- +// 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, GPIO_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, GPIO_CONSUMER, flags) + : gpiod_line_request_input_flags(_line, GPIO_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, GPIO_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 +} + +bool 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. + // + // True, not false: with no edge detection eventFd() is -1, so the loop never + // polled this source and there is nothing it failed to drain. Failure here + // means "the descriptor is still readable and I could not clear it", which + // is not the case. + if (!_edge_ok || _line == NULL) return true; + + // Read until the queue is empty, reporting anything that is not an empty + // queue as a failure. Both the wait and the read can fail persistently (EIO + // on a wedged controller is the same class of fault readPinHardware() + // already guards against), and either one leaves the descriptor readable -- + // so they have to be told apart from "drained", not merged into a bare + // `break` that looks like success to the caller. +#if EVGPIO_GPIOD_V == 1 + // Zero timeout keeps the wait 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; + for (;;) { + int rv = gpiod_line_event_wait(_line, &zero); + if (rv == 0) return true; // queue empty: fully drained + if (rv < 0) return false; // the wait itself failed + if (gpiod_line_event_read(_line, &ev) != 0) return false; + } +#else + for (;;) { + int rv = gpiod_line_request_wait_edge_events(_line, 0); + if (rv == 0) return true; + if (rv < 0) return false; + if (gpiod_line_request_read_edge_events(_line, _evbuf, 16) <= 0) return false; + } +#endif +} + +#undef GPIO_CONSUMER + +#endif // ARDULINUX_HARDWARE diff --git a/variants/linux/EventGPIOPin.h b/variants/linux/EventGPIOPin.h new file mode 100644 index 0000000000..35f10ef14f --- /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; + bool 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 2f4c08465c..0685c1397b 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -4,11 +4,15 @@ #include #include #include +#include #include #ifdef ARDULINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" +#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 +49,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 +163,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 +195,104 @@ 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; +}; + +// Stands in for RadioIrqLevel when no IRQ pin is configured: there is no line +// to sample, so the level never asserts and only the deadline can end the wait. +class NoIrqLevel : public LinuxIrqLevel { +public: + bool irqAsserted() override { return false; } +}; + +} // namespace + +bool LinuxBoard::waitForRadioIrq(uint32_t timeout_ms) { + // Callers read the operation's result over SPI, so a line that never reports + // costs them the wait, not the answer -- but only if the wait actually + // happens. Returning at once here instead would cost them the answer: the + // caller reads a status register the modem has not had time to latch, and + // for CAD that means SX126x::getChannelScanResult() sees neither CAD_DONE + // nor CAD_DETECTED and returns RADIOLIB_ERR_UNKNOWN, which + // isChannelActive() reads as busy -- every channel, every transmit. + // + // So the unconfigured pin waits out the deadline exactly like a dead line + // does, just with nothing to sample and nothing to block on: NoIrqLevel + // never asserts and irq_event_source is NULL (it is only ever set when a pin + // is configured), leaving waitForIrqAsserted() to run its bounded 1 ms-slice + // fallback to the deadline. + if (config.lora_irq_pin == RADIOLIB_NC) { + NoIrqLevel none; + return waitForIrqAsserted(none, irqEventSource(), EventLoop, timeout_ms); + } + + 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..edd32e008a 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,34 @@ 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 instead of spinning. Defined in + // LinuxBoard.cpp; see variants/linux/LinuxEventLoop.h for the poll wrapper, + // which can watch further descriptors alongside it if a caller has any it + // will drain in the same iteration. + 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. A line that cannot report -- dead, + // or never configured at all -- costs the caller the full timeout and a + // false return, never a short-circuited one: the caller reads the result over + // SPI afterwards, and that read is only meaningful once the operation has had + // its deadline to complete. + 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..20e7d49df6 --- /dev/null +++ b/variants/linux/LinuxEventLoop.cpp @@ -0,0 +1,93 @@ +#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) { + // A drain that fails leaves the descriptor readable, which is the same + // shape of problem as POLLNVAL above and needs the same answer. Reporting + // the wake instead would hand the caller a descriptor it cannot clear: + // poll() would return it again immediately, forever, at 100% of a core and + // with nothing further in the log. + if (!_source->drainEvents()) { + usleep(EVENT_LOOP_ERROR_BACKOFF_US); + return 0; + } + } + return readable; +} diff --git a/variants/linux/LinuxEventLoop.h b/variants/linux/LinuxEventLoop.h new file mode 100644 index 0000000000..3baa91a718 --- /dev/null +++ b/variants/linux/LinuxEventLoop.h @@ -0,0 +1,61 @@ +#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. +// +// Besides the single wake-up source, registerFd() takes any other pollable +// descriptor the caller wants included in the same wait. reset() drops the +// whole set, so the intended usage is to rebuild it each iteration and let +// descriptors that only exist part of the time (a device opened on demand, an +// accepted client socket) simply not be registered on the iterations they are +// absent -- there is nothing to deregister. +class LinuxEventLoop { +public: + static const int MAX_FDS = 8; + + // Drop all registered descriptors and the event source. + void reset(); + + // Watch a descriptor for readability until the next reset(). Negative + // descriptors are ignored rather than rejected, so a caller can hand over + // "the fd if I have one" unconditionally; duplicates and anything beyond + // MAX_FDS are dropped too. + // + // Only register a descriptor the caller will actually read this iteration. + // POLLIN is level-triggered: one that nothing drains stays readable forever + // and turns every wait() into an immediate return. + 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, a poll() failure other than + // EINTR, or an event source that reports it could not drain — sleep 1 ms and + // report 0. poll() returns a positive count for POLLNVAL, and a descriptor + // that failed to drain stays readable, so without this either one 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..81449888a4 --- /dev/null +++ b/variants/linux/LinuxEventSource.h @@ -0,0 +1,28 @@ +#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. + // + // Returns false if the queue could not be emptied -- a read error on the + // event descriptor, say. That has to be reported rather than swallowed: + // the descriptor is then still readable, so a caller told the wait + // succeeded would poll(), be woken instantly, fail to drain again, and + // spin at 100% of a core with nothing in the log. LinuxEventLoop::wait() + // backs off instead. Returning true with nothing drained is correct only + // when there was nothing queued. + virtual bool drainEvents() = 0; +}; diff --git a/variants/linux/LinuxRadioWait.cpp b/variants/linux/LinuxRadioWait.cpp new file mode 100644 index 0000000000..1bb57f4a9a --- /dev/null +++ b/variants/linux/LinuxRadioWait.cpp @@ -0,0 +1,70 @@ +#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 every degenerate case that would otherwise return instantly + // forever (POLLNVAL, POLLHUP, poll() failure, and a source that reports it + // could not drain). + // + // 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..f78fdd18dc --- /dev/null +++ b/variants/linux/LinuxRadioWait.h @@ -0,0 +1,57 @@ +#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, on any path. Each iteration blocks in poll() on src's edge +// descriptor for the whole remaining time; where src has no usable descriptor +// (including src == NULL) it waits in 1 ms slices instead, the same fallback +// (and the same value) as LinuxBoard::idleUntilEvent(). The one way an +// iteration can return with no time spent is a descriptor that stays readable +// -- stale, hung up, or failing to drain -- and LinuxEventLoop::wait() applies +// its own cool-off to every one of those before returning, which is what makes +// the guarantee hold rather than merely being the intent. +// +// 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 one, 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 e2405b4973..816a992952 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -281,9 +281,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 `prefs.json` 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. +- **No suspend or wake-on-radio**, `board.sleep()` is an ordinary `sleep(3)`: the process idles, but the host is not suspended and nothing arms a wake source, so `powersaving_enabled` saves no more power than the normal idle described under [Idle CPU usage](#idle-cpu-usage). It also cannot be interrupted by an incoming packet, so leaving it off is the better default on Linux. +- **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.