From c6d9f783f36db135b4a2498b49f13b025352aed0 Mon Sep 17 00:00:00 2001 From: mmmorks Date: Mon, 7 Sep 2026 14:19:22 -0700 Subject: [PATCH] variants/linux: GPS from a serial device or gpsd, and host-clock discipline The Linux build had ENV_INCLUDE_GPS off because ardulinux has no hardware UART: EnvironmentSensorManager::initBasicGPS() probes Serial1, which does not exist here. This adds a GPS byte source for Linux and wires the shared MicroNMEALocationProvider over it, so the standard `gps` CLI commands and location telemetry work on a Pi exactly as on an MCU. LinuxGpsStream is an Arduino Stream over one of two transports selected by `gps_device` in meshcored.ini: * a serial device (`/dev/ttyS0`, `/dev/ttyACM0`, ...) opened directly, with `gps_baud` programmed via termios. A configured device that opens is treated as detected -- the byte-sniff initBasicGPS() does on MCUs raced against receivers that take many seconds to emit their first sentence and permanently disabled the `gps` CLI surface when it lost. A device that disappears afterwards -- a USB receiver unplugged, whose read() then returns EIO forever -- is closed, reported once, and reopened on the same backoff the gpsd path uses, rather than leaving the fd open, isPresent() saying true and the GPS silently dead. * `gpsd://[host][:port]`, NMEA passthrough from gpsd over a socket. This is what lets gpsd own the receiver and so lets chrony discipline the host clock, which matters more on Linux than on an MCU: the node's clock is the whole host's clock. The connect is a non-blocking state machine with backoff (DNS resolved once at startup), so a gpsd that is slow, absent or restarted underneath the daemon never stalls radio RX. gpsd's JSON control lines fail MicroNMEA's leading-$-and-checksum test and are discarded; the test pins that down with the real parser. Both transports handle a `gps off` / `gps on` gap: the sensor manager only drains the stream while GPS is active, so a socket reconnects and a tty flushes its kernel backlog after >5 s of silence, rather than replaying stale fixes as current ones. `gps_en_pin` binds a GPIO and holds it HIGH for the daemon's lifetime, for receivers that boot into standby (the L76K on the Waveshare LoRaWAN/GNSS HAT sends nothing until its STANDBY line is driven). Root-caused on that hardware: holding GPIO 4 high made the node report a fix with 19 sats. Non-fatal, since a repeater must run without GPS, and the daemon says when the claim fails on a host that hogs the line itself. With gpsd owning the receiver, chrony owns the clock, so LinuxRTCClock can be told the clock is externally disciplined and then declines to set it -- which also makes "a remote mesh peer cannot retime a general-purpose host via `clock sync`" true by configuration rather than by the accident of the shipped unit lacking CAP_SYS_TIME. The default follows the transport (gpsd:// -> defer); `defer_clock = true/false` overrides it for setups the transport string cannot see, e.g. a serial device whose NMEA is fed to chrony some other way. Where the clock is still ours to set, setCurrentTime() is now bounded. ardulinux derives millis() from CLOCK_REALTIME (gettimeofday() minus a start offset captured once), so a step of the system clock steps every deadline already in flight -- Dispatcher::millisHasNowPassed(), the CAD retry, the delayed-inbound queue -- by the same amount. Two concrete cases motivate the bounds: MicroNMEA's year comes from RMC while isValid() is satisfied by GGA, so a date-less fix yields year 0 and a step back to 2000 that underflows millis()'s `now - startMsec`; and NMEA names a second that has already begun (measured 0.193-0.384 s late on this HAT), so the re-sync every TIME_SYNC_INTERVAL was stepping the clock backwards by that fraction 48 times a day. So: refuse a timestamp older than 2024, refuse a jump of more than 24 h once the clock has been set once, and slew sub-second corrections with adjtime() instead of stepping. Shared code touched, all needed for the above: * EnvironmentSensorManager::initBasicGPS(): an ARDULINUX_PLATFORM branch that asks the variant whether a GPS device is open instead of probing Serial1. The MCU path is untouched. * LocationProvider::sendSentence() gets an inline empty body. It was declared virtual with no definition anywhere, which makes it the class's key function and so leaves the vtable un-emitted under the Itanium ABI: the first subclass constructed in an unoptimised build fails to link, which is exactly what turning ENV_INCLUDE_GPS on here does. Every subclass already overrides it, so no behaviour changes. * CommonCLI: bare `gps` hands the NULL that getSettingByKey("gps") returns, when no GPS setting is registered, straight to strcmp(). This commit is what makes that reachable on Linux -- it constructs a LocationProvider unconditionally, so the `_location != NULL` test above no longer short-circuits to "error" on a node whose GPS was never detected. `clock sync` / `time ` also reply "ERR: clock set was refused" when the clock did not move, instead of "OK": on Linux the set genuinely can be refused (defer_clock, the bounds above, or no CAP_SYS_TIME); on every other target it always takes and the reply is unchanged. meshcored.ini gains gps_device, gps_baud (validated against the bauds termios can program), gps_en_pin and defer_clock; the templates document them, the Waveshare one with that HAT's UART and STANDBY wiring. gps_device holds 255 characters rather than 63, and reports anything longer as an invalid value: the /dev/serial/by-id/ names the templates recommend run to 74 and 81 characters, and a silently truncated path still parses as a device path and then merely fails to open, which reads as an absent receiver. The README covers serial GPS, the gpsd + chrony setup (including the refclock offset chrony needs before it will believe an NMEA-only source), and keeping the GNSS stack up when meshcored is not running. The Waveshare-HAT hardware guide -- the PPS tap, the device-tree overlay, handing the pulse to chrony and serving the result to the LAN -- is in variants/linux/docs/gnss-pps-hardware.md, since no line of code here reaches it. --- platformio.ini | 12 + src/helpers/CommonCLI.cpp | 36 +- .../sensors/EnvironmentSensorManager.cpp | 18 + src/helpers/sensors/LocationProvider.h | 2 +- test/mocks/Arduino.h | 1 + test/mocks/Mesh.h | 6 + .../test_linux_gps_stream.cpp | 525 ++++++++++++++++++ variants/linux/LinuxBoard.cpp | 107 +++- variants/linux/LinuxBoard.h | 147 ++++- variants/linux/LinuxGpsStream.cpp | 385 +++++++++++++ variants/linux/LinuxGpsStream.h | 118 ++++ variants/linux/PeekableStream.h | 40 ++ variants/linux/README.md | 245 +++++++- variants/linux/docs/gnss-pps-hardware.md | 198 +++++++ variants/linux/meshcored.ini | 16 + variants/linux/meshcored.ini.pow-sx1262 | 7 + variants/linux/meshcored.ini.waveshare | 24 + variants/linux/platformio.ini | 2 + variants/linux/target.cpp | 22 +- variants/linux/target.h | 9 + 20 files changed, 1899 insertions(+), 21 deletions(-) create mode 100644 test/test_linux_gps_stream/test_linux_gps_stream.cpp create mode 100644 variants/linux/LinuxGpsStream.cpp create mode 100644 variants/linux/LinuxGpsStream.h create mode 100644 variants/linux/PeekableStream.h create mode 100644 variants/linux/docs/gnss-pps-hardware.md diff --git a/platformio.ini b/platformio.ini index cbc83957d3..a6a26aee4d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -224,6 +224,13 @@ test_framework = googletest build_flags = -std=c++17 -I src -I test/mocks + -I variants/linux +; MicroNMEA is reached only from a test file, and a lib_deps entry that nothing +; under build_src_filter includes gets neither its include dir onto the test +; compile line nor its archive onto the link line (verified: dropping either of +; the two lines below fails, the first to compile and the second to link). So +; name both explicitly; lib_deps still pins the version and fetches it. + -I .pio/libdeps/native/MicroNMEA/src test_build_src = yes test_ignore = test_kiss_modem build_src_filter = @@ -232,8 +239,13 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../variants/linux/LinuxGpsStream.cpp> + +<../.pio/libdeps/native/MicroNMEA/src/MicroNMEA.cpp> lib_deps = google/googletest @ 1.17.0 +; test_linux_gps_stream includes to prove with the real parser +; that gpsd's JSON control lines cannot corrupt a fix. + stevemarple/MicroNMEA @ ^2.0.6 [env:native_kiss_modem] platform = native diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 96c7fa92f9..5c140a9dc4 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -19,6 +19,17 @@ static uint32_t _atoi(const char* sp) { return n; } +// setCurrentTime() returns void, so this is the only signal available that a +// set was honoured. On Linux it can be silently refused (host clock owned by +// chrony, or the process lacks CAP_SYS_TIME) and getCurrentTime() then reads +// back the unchanged clock; on every other target the set always takes, so +// this is always true there. A couple of seconds of slack absorbs a hardware +// RTC's read-back latency, not a real failure. +static bool clockSetTookEffect(uint32_t target, uint32_t actual) { + int64_t diff = (int64_t)actual - (int64_t)target; + return diff >= -2 && diff <= 2; +} + static bool isValidName(const char *n) { while (*n) { if (*n == '[' || *n == ']' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false; @@ -210,10 +221,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "clock sync", 10) == 0) { uint32_t curr = getRTCClock()->getCurrentTime(); if (sender_timestamp > curr) { - getRTCClock()->setCurrentTime(sender_timestamp + 1); + uint32_t target = sender_timestamp + 1; + getRTCClock()->setCurrentTime(target); uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + if (clockSetTookEffect(target, now)) { + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else { + strcpy(reply, "ERR: clock set was refused"); + } } else { strcpy(reply, "ERR: clock cannot go backwards"); } @@ -231,8 +247,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (secs > curr) { getRTCClock()->setCurrentTime(secs); uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + if (clockSetTookEffect(secs, now)) { + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else { + strcpy(reply, "(ERR: clock set was refused)"); + } } else { strcpy(reply, "(ERR: clock cannot go backwards)"); } @@ -396,7 +416,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re bool enabled = l->isEnabled(); // is EN pin on ? bool fix = l->isValid(); // has fix ? int sats = l->satellitesCount(); - bool active = !strcmp(_sensors->getSettingByKey("gps"), "1"); + // getSettingByKey() returns NULL when no "gps" setting is registered + // (e.g. no GPS detected -- the Linux default) -- treat that as + // "deactivated" rather than handing strcmp() a NULL, which segfaults. + const char* gps_setting = _sensors->getSettingByKey("gps"); + bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0; if (enabled) { sprintf(reply, "on, %s, %s, %d sats", active?"active":"deactivated", diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 6f4607751c..232ea12808 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -747,8 +747,25 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val } #if ENV_INCLUDE_GPS +#if defined(ARDULINUX_PLATFORM) +// Defined in variants/linux/target.cpp. The Linux build opens the serial GPS +// device itself (there is no hardware UART); this reports whether that device +// is open. +bool linux_gps_present(); +#endif + void EnvironmentSensorManager::initBasicGPS() { +#if defined(ARDULINUX_PLATFORM) + // Linux: the serial device is opened by the variant (target.cpp) from the + // gps_device/gps_baud config. A configured+opened device is treated as + // detected (no byte-sniff, which raced against slow-to-fix devices); + // runtime loop()/isValid() reports actual fix state. If nothing was + // configured/opened, GPS is off. + _location->begin(); // no-op on Linux (pin_en/pin_reset == -1) + _location->reset(); + gps_detected = linux_gps_present(); +#else Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); #ifdef GPS_BAUD_RATE @@ -774,6 +791,7 @@ void EnvironmentSensorManager::initBasicGPS() { #else gps_detected = (Serial1.available() > 0); #endif +#endif // ARDULINUX_PLATFORM if (gps_detected) { MESH_DEBUG_PRINTLN("GPS detected"); diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 81d08652ed..488a755115 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -16,7 +16,7 @@ class LocationProvider { virtual long satellitesCount() = 0; virtual bool isValid() = 0; virtual long getTimestamp() = 0; - virtual void sendSentence(const char * sentence); + virtual void sendSentence(const char * sentence) {} virtual void reset() = 0; virtual void begin() = 0; virtual void stop() = 0; diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe414..efdff75593 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -2,6 +2,7 @@ #include #include +#include // real Arduino.h pulls this in; MicroNMEA relies on it #include "Stream.h" inline uint32_t g_mock_millis = 0; diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h index b6c263c199..8e79fb3eb0 100644 --- a/test/mocks/Mesh.h +++ b/test/mocks/Mesh.h @@ -25,3 +25,9 @@ class MainBoard { }; } + +// LinuxGpsStream.cpp logs through this. The real definition lives in +// src/MeshCore.h, which the native test env does not build. +#ifndef MESH_DEBUG_PRINTLN + #define MESH_DEBUG_PRINTLN(...) {} +#endif diff --git a/test/test_linux_gps_stream/test_linux_gps_stream.cpp b/test/test_linux_gps_stream/test_linux_gps_stream.cpp new file mode 100644 index 0000000000..e223aed280 --- /dev/null +++ b/test/test_linux_gps_stream/test_linux_gps_stream.cpp @@ -0,0 +1,525 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +// openpty(): macOS declares it in ; glibc in . +#if defined(__APPLE__) +#include +#else +#include +#endif + +#include +#include + +#include "LinuxGpsStream.h" + +namespace { + +using Target = LinuxGpsStream::Target; + +// The GPS descriptor is protected so the event loop cannot register it (see +// LinuxBoard::idleUntilEvent). Tests still have to see it, to check that it +// cannot survive the execv() in LinuxBoard::reboot(). +class TestGpsStream : public LinuxGpsStream { +public: + using LinuxGpsStream::fd; +}; + +TEST(ParseDevice, EmptyIsNoTransportButValid) { + Target t = LinuxGpsStream::parseDevice(""); + EXPECT_TRUE(t.valid); + EXPECT_EQ(t.transport, LinuxGpsStream::NO_SOURCE); +} + +TEST(ParseDevice, NullIsNoTransportButValid) { + Target t = LinuxGpsStream::parseDevice(nullptr); + EXPECT_TRUE(t.valid); + EXPECT_EQ(t.transport, LinuxGpsStream::NO_SOURCE); +} + +TEST(ParseDevice, DevicePathIsSerial) { + Target t = LinuxGpsStream::parseDevice("/dev/ttyS0"); + EXPECT_TRUE(t.valid); + EXPECT_EQ(t.transport, LinuxGpsStream::SERIAL_DEVICE); +} + +TEST(ParseDevice, BareSchemeUsesDefaults) { + Target t = LinuxGpsStream::parseDevice("gpsd://"); + ASSERT_TRUE(t.valid); + EXPECT_EQ(t.transport, LinuxGpsStream::GPSD_SOCKET); + EXPECT_STREQ(t.host, "127.0.0.1"); + EXPECT_EQ(t.port, 2947); +} + +TEST(ParseDevice, HostOnlyUsesDefaultPort) { + Target t = LinuxGpsStream::parseDevice("gpsd://gpsbox.local"); + ASSERT_TRUE(t.valid); + EXPECT_STREQ(t.host, "gpsbox.local"); + EXPECT_EQ(t.port, 2947); +} + +TEST(ParseDevice, HostAndPort) { + Target t = LinuxGpsStream::parseDevice("gpsd://10.0.0.5:3000"); + ASSERT_TRUE(t.valid); + EXPECT_STREQ(t.host, "10.0.0.5"); + EXPECT_EQ(t.port, 3000); +} + +TEST(ParseDevice, EmptyHostWithPortUsesDefaultHost) { + Target t = LinuxGpsStream::parseDevice("gpsd://:3000"); + ASSERT_TRUE(t.valid); + EXPECT_STREQ(t.host, "127.0.0.1"); + EXPECT_EQ(t.port, 3000); +} + +TEST(ParseDevice, RejectsMissingPortAfterColon) { + EXPECT_FALSE(LinuxGpsStream::parseDevice("gpsd://host:").valid); +} + +TEST(ParseDevice, RejectsNonNumericPort) { + EXPECT_FALSE(LinuxGpsStream::parseDevice("gpsd://host:abc").valid); +} + +TEST(ParseDevice, RejectsZeroPort) { + EXPECT_FALSE(LinuxGpsStream::parseDevice("gpsd://host:0").valid); +} + +TEST(ParseDevice, RejectsOutOfRangePort) { + EXPECT_FALSE(LinuxGpsStream::parseDevice("gpsd://host:70000").valid); +} + +// Only hostnames and IPv4 are supported. A bare IPv6 literal is ambiguous +// against the host:port split, so it is rejected loudly rather than +// misparsed -- bad_values makes LinuxBoard::begin() refuse to start. +TEST(ParseDevice, RejectsMultipleColons) { + EXPECT_FALSE(LinuxGpsStream::parseDevice("gpsd://::1:2947").valid); +} + +TEST(ParseDevice, RejectsOverlongHost) { + std::string url = "gpsd://"; + url.append(80, 'a'); + EXPECT_FALSE(LinuxGpsStream::parseDevice(url.c_str()).valid); +} + +// Minimal stand-in for gpsd: listens on an ephemeral loopback port, accepts one +// client, and lets the test push bytes at it. Enough to exercise the handshake, +// the read path and a mid-stream disconnect. +class FakeGpsd { +public: + FakeGpsd() { + _listen = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // ephemeral + bind(_listen, (sockaddr*)&addr, sizeof addr); + listen(_listen, 4); + // Non-blocking: acceptClient() is polled from loops that also have to make + // progress when the stream has not (re)connected yet. A blocking accept() + // deadlocks those. + fcntl(_listen, F_SETFL, fcntl(_listen, F_GETFL, 0) | O_NONBLOCK); + socklen_t len = sizeof addr; + getsockname(_listen, (sockaddr*)&addr, &len); + _port = ntohs(addr.sin_port); + } + ~FakeGpsd() { closeClient(); if (_listen >= 0) close(_listen); } + + int port() const { return _port; } + + // Accept a pending connection. Returns false if none arrived. + // accept() + fcntl() rather than accept4(): these tests build on the host, + // which may be macOS, where accept4()/SOCK_NONBLOCK do not exist. + // Always tries to accept, so a reconnect is picked up even while an older + // client fd is still held. acceptCount() is then how a test distinguishes + // "same connection" from "connected again". + bool acceptClient() { + sockaddr_in from{}; + socklen_t len = sizeof from; + int fd = accept(_listen, (sockaddr*)&from, &len); + if (fd < 0) return _client >= 0; + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); + if (_client >= 0) close(_client); + _client = fd; + _accepts++; + return true; + } + + int acceptCount() const { return _accepts; } + + void send(const std::string& s) { if (_client >= 0) ::write(_client, s.data(), s.size()); } + + // Accumulate whatever the client has written (the WATCH command). + std::string received() { + char buf[512]; + while (_client >= 0) { + ssize_t n = ::recv(_client, buf, sizeof buf, MSG_DONTWAIT); + if (n <= 0) break; + _rx.append(buf, n); + } + return _rx; + } + + void closeClient() { if (_client >= 0) { close(_client); _client = -1; } } + void forgetRx() { _rx.clear(); } + +private: + int _listen = -1; + int _client = -1; + int _port = 0; + int _accepts = 0; + std::string _rx; +}; + +std::string gpsdUrl(int port) { + return "gpsd://127.0.0.1:" + std::to_string(port); +} + +// Drive the non-blocking connect state machine to completion. The first reads +// legitimately return -1 while connect() is still in flight. +void settle(LinuxGpsStream& s, FakeGpsd& fake, int rounds = 200) { + for (int i = 0; i < rounds; i++) { + fake.acceptClient(); + s.read(); + usleep(500); + } +} + +// Pump the stream until it yields a byte or the budget runs out. +int readWithin(LinuxGpsStream& s, FakeGpsd& fake, int attempts = 400) { + for (int i = 0; i < attempts; i++) { + fake.acceptClient(); + int c = s.read(); + if (c >= 0) return c; + usleep(500); + } + return -1; +} + +TEST(GpsdTransport, ConnectsAndSendsWatch) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + EXPECT_EQ(s.transport(), LinuxGpsStream::GPSD_SOCKET); + + settle(s, fake); + + std::string got = fake.received(); + EXPECT_NE(got.find("?WATCH="), std::string::npos) << "got: " << got; + EXPECT_NE(got.find("\"nmea\":true"), std::string::npos) << "got: " << got; +} + +TEST(GpsdTransport, ReadsNmeaBytes) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + fake.send("$GNGGA,x\r\n"); + EXPECT_EQ(readWithin(s, fake), '$'); +} + +// The point of the whole design: gpsd interleaves JSON control lines with the +// NMEA, and MicroNMEA must ignore them rather than be corrupted by them. +TEST(GpsdTransport, JsonBannerDoesNotCorruptFixParsing) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + + fake.send("{\"class\":\"VERSION\",\"release\":\"3.22\",\"rev\":\"3.22\"}\r\n"); + fake.send("{\"class\":\"DEVICES\",\"devices\":[{\"path\":\"/dev/ttyS0\"}]}\r\n"); + fake.send("$GNGGA,214011.000,4739.71889,N,12219.58334,W,1,18,0.8,80.5,M,-21.6,M,,*45\r\n"); + + char buf[100]; + MicroNMEA nmea(buf, sizeof buf); + for (int i = 0; i < 6000; i++) { + fake.acceptClient(); + int c = s.read(); + if (c >= 0) nmea.process((char)c); + else usleep(200); + } + + EXPECT_TRUE(nmea.isValid()); + EXPECT_EQ(nmea.getNumSatellites(), 18); +} + +// A gpsd that has not started yet must not disable GPS for the whole boot: +// initBasicGPS() asks isPresent() exactly once. +TEST(GpsdTransport, IsPresentWhenConfiguredButUnreachable) { + LinuxGpsStream s; + ASSERT_TRUE(s.begin("gpsd://127.0.0.1:1", 9600)); // nothing listens on :1 + EXPECT_TRUE(s.isPresent()); +} + +TEST(GpsdTransport, NotPresentWhenUnconfigured) { + LinuxGpsStream s; + s.begin("", 9600); + EXPECT_FALSE(s.isPresent()); +} + +TEST(GpsdTransport, WriteIsNoOp) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + EXPECT_EQ(s.write((uint8_t)'$'), 0u); +} + +TEST(GpsdTransport, InvalidUrlFailsBegin) { + LinuxGpsStream s; + EXPECT_FALSE(s.begin("gpsd://host:abc", 9600)); + EXPECT_FALSE(s.isPresent()); +} + +// LinuxBoard::reboot() re-execs this process image, and execv() keeps every +// descriptor that is not close-on-exec. Neither of these is reachable from the +// new image, so each reboot would otherwise leak one for the life of the +// rebooted daemon -- and hold gpsd's client slot open with nobody reading it. +TEST(GpsdTransport, SocketIsCloseOnExec) { + FakeGpsd fake; + TestGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + + ASSERT_GE(s.fd(), 0); + EXPECT_TRUE(fcntl(s.fd(), F_GETFD) & FD_CLOEXEC); +} + +TEST(GpsdReconnect, ReconnectsAfterServerDrop) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + fake.send("$A\r\n"); + ASSERT_EQ(readWithin(s, fake), '$'); + + fake.closeClient(); + for (int i = 0; i < 50; i++) { s.read(); usleep(500); } // observe the hangup + + g_mock_millis += 60000; // past the backoff + settle(s, fake); + fake.send("$B\r\n"); + EXPECT_EQ(readWithin(s, fake), '$'); +} + +// gps off stops the drain. An undrained socket fills its receive window and +// gpsd drops clients it cannot write to, and on gps on the backlog would be +// parsed as if current. Reconnecting after a gap avoids both. +TEST(GpsdReconnect, ReconnectsAfterReadGap) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + fake.send("$A\r\n"); + ASSERT_EQ(readWithin(s, fake), '$'); + + ASSERT_EQ(fake.acceptCount(), 1); + + // The server stays up and the connection stays healthy. A gap in reads alone + // must force a fresh connection -- otherwise this passes for the wrong + // reason, via the server-drop path rather than the gap. + // One time step only: a gap-drop reconnects immediately rather than backing + // off, so advancing again here would trip the gap a second time and the + // count would be ambiguous. + g_mock_millis += 10000; // longer than the 5 s gap threshold + settle(s, fake); + + EXPECT_EQ(fake.acceptCount(), 2) << "a read gap should have reconnected"; +} + +// The negative case for the test above: a pause shorter than the threshold +// is normal poll-loop jitter, not a `gps off`, and must not pay the cost of +// a reconnect. +TEST(GpsdReconnect, ShortReadGapDoesNotReconnect) { + FakeGpsd fake; + LinuxGpsStream s; + ASSERT_TRUE(s.begin(gpsdUrl(fake.port()).c_str(), 9600)); + settle(s, fake); + fake.send("$A\r\n"); + ASSERT_EQ(readWithin(s, fake), '$'); + + ASSERT_EQ(fake.acceptCount(), 1); + + g_mock_millis += 2000; // well under the 5 s gap threshold + settle(s, fake); + + EXPECT_EQ(fake.acceptCount(), 1) << "a short gap must not force a reconnect"; +} + +TEST(GpsdReconnect, UnreachableGpsdKeepsRetryingWithoutSpinning) { + LinuxGpsStream s; + ASSERT_TRUE(s.begin("gpsd://127.0.0.1:1", 9600)); // nothing listens + for (int i = 0; i < 50; i++) s.read(); + // The point is that it neither crashes nor gives up: reads keep returning -1 + // and the stream stays present for a gpsd that may yet start. + EXPECT_EQ(s.read(), -1); + EXPECT_TRUE(s.isPresent()); +} + +// A short-write test for finishConnect()'s partial-WATCH-write path was +// attempted and dropped: the command is ~38 bytes, and forcing write() to +// return short/EAGAIN for a payload that small requires shrinking the +// *client* socket's own SO_SNDBUF below ~38 bytes. Shrinking the fake +// server's SO_RCVBUF (the only buffer a test harness can reach here) does +// not do it -- write() succeeds once data fits in the local kernel send +// buffer, regardless of the peer's advertised window, and LinuxGpsStream +// exposes no hook to shrink the client fd's own SO_SNDBUF. The dropConnection() +// call on the short-write path is exercised by code review and by the +// existing error-path tests (invalid URL, unreachable host) taking the same +// dropConnection() branch instead. + +// Minimal pty-backed stand-in for a real /dev/tty* GPS receiver. openpty() +// hands back a connected master/slave pair; the slave is closed immediately +// so LinuxGpsStream can open the path itself (mirroring how it opens a real +// device node), and the test drives bytes in through the master side. +class FakePtyGps { +public: + FakePtyGps() { + int master = -1, slave = -1; + char name[256] = {}; + if (openpty(&master, &slave, name, nullptr, nullptr) != 0) return; + close(slave); // LinuxGpsStream::openSerial() opens its own fd on `name` + fcntl(master, F_SETFL, fcntl(master, F_GETFL, 0) | O_NONBLOCK); + _master = master; + _path = name; + _ok = true; + } + ~FakePtyGps() { if (_master >= 0) close(_master); } + + bool ok() const { return _ok; } + const char* path() const { return _path.c_str(); } + int masterFd() const { return _master; } + void send(const std::string& s) { ::write(_master, s.data(), s.size()); } + +private: + bool _ok = false; + int _master = -1; + std::string _path; +}; + +// Pump the stream until it yields a byte or the budget runs out. No accept() +// step needed (unlike the gpsd readWithin()): a pty has no listen/accept +// phase, so LinuxGpsStream::begin() has the device open by the time this runs. +int readSerialWithin(LinuxGpsStream& s, int attempts = 400) { + for (int i = 0; i < attempts; i++) { + int c = s.read(); + if (c >= 0) return c; + usleep(500); + } + return -1; +} + +TEST(SerialGpsStream, OpensDeviceAndReadsBytes) { + FakePtyGps pty; + ASSERT_TRUE(pty.ok()); + + LinuxGpsStream s; + ASSERT_TRUE(s.begin(pty.path(), 9600)); + EXPECT_EQ(s.transport(), LinuxGpsStream::SERIAL_DEVICE); + EXPECT_TRUE(s.isPresent()); + + pty.send("$GPGGA,x\r\n"); + EXPECT_EQ(readSerialWithin(s), '$'); +} + +// The serial-path counterpart of GpsdTransport.SocketIsCloseOnExec. +TEST(SerialGpsStream, DeviceIsCloseOnExec) { + FakePtyGps pty; + ASSERT_TRUE(pty.ok()); + + TestGpsStream s; + ASSERT_TRUE(s.begin(pty.path(), 9600)); + + ASSERT_GE(s.fd(), 0); + EXPECT_TRUE(fcntl(s.fd(), F_GETFD) & FD_CLOEXEC); +} + +// baud_to_speed() is a private implementation detail; verify indirectly by +// reading back the termios settings the tty ends up with. A pty's line +// discipline state is shared by both ends, so what LinuxGpsStream configured +// through the slave path is visible via the master fd this test still holds. +TEST(SerialGpsStream, BaudIsAppliedToTheDevice) { + FakePtyGps pty; + ASSERT_TRUE(pty.ok()); + + LinuxGpsStream s; + ASSERT_TRUE(s.begin(pty.path(), 19200)); + + struct termios tio; + ASSERT_EQ(tcgetattr(pty.masterFd(), &tio), 0); + EXPECT_EQ(cfgetispeed(&tio), (speed_t)B19200); + EXPECT_EQ(cfgetospeed(&tio), (speed_t)B19200); +} + +// The serial-path analogue of GpsdReconnect.ReconnectsAfterReadGap: `gps off` +// stops EnvironmentSensorManager from draining the stream, so bytes queue in +// the tty's kernel input buffer while nothing reads them. `gps on` must not +// replay that backlog as though it were current (finding 4). +TEST(SerialGpsStream, StaleBacklogFlushedAfterReadGap) { + FakePtyGps pty; + ASSERT_TRUE(pty.ok()); + + LinuxGpsStream s; + ASSERT_TRUE(s.begin(pty.path(), 9600)); + + // Establish a first successful read so _last_read_ms is seeded -- the gap + // check is a no-op until then. + pty.send("$A\r\n"); + ASSERT_EQ(readSerialWithin(s), '$'); + for (int i = 0; i < 3; i++) readSerialWithin(s); // drain "A\r\n" + + // Simulate the backlog that piles up while `gps off` stops the drain. + pty.send("$STALE,should,not,appear\r\n"); + usleep(20000); // let the kernel queue it on the slave side before the gap + + g_mock_millis += 10000; // longer than the 5 s gap threshold + + // The read that resumes after the gap must flush the backlog rather than + // hand any of it back. + EXPECT_EQ(s.read(), -1); + + // Fresh data written after the flush is still delivered normally. + pty.send("$FRESH\r\n"); + EXPECT_EQ(readSerialWithin(s), '$'); +} + +// Negative case: a pause shorter than the threshold is normal poll-loop +// jitter, not a `gps off`, and must not discard data that is simply waiting +// to be read. +TEST(SerialGpsStream, ShortReadGapDoesNotFlush) { + FakePtyGps pty; + ASSERT_TRUE(pty.ok()); + + LinuxGpsStream s; + ASSERT_TRUE(s.begin(pty.path(), 9600)); + + pty.send("$A\r\n"); + ASSERT_EQ(readSerialWithin(s), '$'); + for (int i = 0; i < 3; i++) readSerialWithin(s); // drain "A\r\n" + + pty.send("$B\r\n"); + usleep(20000); + + g_mock_millis += 2000; // well under the 5 s gap threshold + + // Retried rather than read once: 20 ms is not a guarantee that the pty has + // handed the bytes over on a contended runner. The assertion still tells the + // two cases apart, because the flush case above asserts -1 on the *first* + // read -- readSerialWithin() would never turn a flushed queue into a '$'. + EXPECT_EQ(readSerialWithin(s), '$') << "a short gap must not flush pending data"; +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index d48f16a0eb..2bf47c8f5b 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -9,6 +9,7 @@ #include "linux/gpio/LinuxGPIOPin.h" #endif #include "LinuxBoard.h" +#include "LinuxGpsStream.h" #include "AppInfo.h" // Still hardcoded -- see "Known Gaps" in variants/linux/README.md -- but named, @@ -105,6 +106,15 @@ void LinuxBoard::begin() { exit(1); } + // Not fatal, but not silent either: gpsd owns the serial port and its baud + // rate, so a gps_baud beside a gpsd:// device does nothing. Saying so beats + // leaving the operator to wonder why changing it has no effect. + if (config.gps_baud != 9600 && + LinuxGpsStream::parseDevice(config.gps_device).transport == LinuxGpsStream::GPSD_SOCKET) { + printf("WARNING: gps_baud is set but gps_device names gpsd, which owns the\n" + " serial port and its baud rate. The value has no effect here.\n"); + } + printf("SPI begin %s\n", config.spidev); SPI.begin(config.spidev, 2000000); @@ -140,6 +150,42 @@ void LinuxBoard::begin() { printf("FATAL: %d GPIO pin(s) failed to bind; cannot start radio.\n", failures); exit(1); } + + // GPS enable/standby pin: some modules (e.g. the L76K on the Waveshare + // LoRaWAN/GNSS HAT) must have their STANDBY line driven HIGH to wake and + // stream NMEA. Bind and hold it high for the daemon lifetime. Non-fatal: a + // repeater must still run without GPS. + // + // "for the daemon lifetime" is the catch, and it is why the warning below + // exists. The line is released when this process exits, leaving it unowned. + // Measured on bookworm/libgpiod 1.6.3 the pad keeps its last state, so the + // receiver does not drop -- but nothing promises that, and nothing stops + // another consumer claiming the line and driving it low. Where gpsd owns the + // receiver, that unowned pin is underneath the host's clock, so the host + // should be the one holding it. + if (config.gps_en_pin != -1) { + if (LinuxGpsStream::parseDevice(config.gps_device).transport == LinuxGpsStream::GPSD_SOCKET) { + printf("WARNING: gps_en_pin is set alongside a gpsd:// gps_device. The pin is\n" + " still driven, but it is released when meshcored exits, leaving\n" + " the receiver -- and so chrony's GNSS source -- resting on an\n" + " unowned line. Prefer holding it from the host (on a Pi:\n" + " dtoverlay=gpio-hog,gpio=%d) and leaving gps_en_pin unset.\n" + " See variants/linux/README.md.\n", + (int)config.gps_en_pin); + } + if (initGPIOPin(config.gps_en_pin, config.lora_gpiochip, config.gps_en_pin) == 0) { + pinMode(config.gps_en_pin, OUTPUT); + digitalWrite(config.gps_en_pin, HIGH); + printf("GPS enable pin %d driven HIGH\n", (int)config.gps_en_pin); + } else { + // Expected, and correct, on a host that hogs the line itself: a hogged + // GPIO is unavailable to any other consumer by design, and the receiver + // is already awake. Only worth acting on if nothing else holds the pin. + printf("WARNING: could not claim GPS enable pin %d; GPS may stay asleep\n" + " (expected if the host holds this line, e.g. a GPIO hog)\n", + (int)config.gps_en_pin); + } + } } // The ardulinux core's global ::reboot() (cores/ardulinux/main.cpp) re-execs @@ -288,6 +334,28 @@ static bool parse_bool(const char *key, const char *value, bool *out, int *bad_v return false; } +// Accept exactly the bauds LinuxGpsStream::openSerial() can program via +// termios (see baud_to_speed() in LinuxGpsStream.cpp, read-only for this +// file); anything else silently falls back to B9600 at open time, which +// reads as a dead GPS rather than a typo'd config value. +static bool parse_gps_baud(const char *value, int *out, int *bad_values) { + static const int VALID_BAUDS[] = { 4800, 9600, 19200, 38400, 57600, 115200 }; + char *end = NULL; + long v = strtol(value, &end, 10); + if (end != value && *end == '\0') { + for (size_t i = 0; i < sizeof(VALID_BAUDS) / sizeof(VALID_BAUDS[0]); i++) { + if (v == VALID_BAUDS[i]) { + *out = (int) v; + return true; + } + } + } + printf("ERROR: meshcored.ini: gps_baud = '%s' is not a supported rate " + "(expected one of 4800, 9600, 19200, 38400, 57600, 115200)\n", value); + (*bad_values)++; + return false; +} + // Copy `value` into a string field, freeing whatever it pointed to if this // field has already been assigned once during this load() call. The // compile-time default is a string literal and not ours to free; `*owned` @@ -327,7 +395,8 @@ LinuxConfig::LoadResult LinuxConfig::load(const char *filename) { // safe_copy() allocation, so a duplicate key knows there is something of // its own to free before overwriting it again. bool spidev_owned = false, lora_gpiochip_owned = false, - advert_name_owned = false, admin_password_owned = false; + advert_name_owned = false, admin_password_owned = false, + gps_device_owned = false; bool first_line = true; char line[512]; @@ -465,6 +534,42 @@ LinuxConfig::LoadResult LinuxConfig::load(const char *filename) { if (parse_float(key, value, &fval, &result.bad_values)) lat = fval; } else if (strcmp(key, "lon") == 0) { if (parse_float(key, value, &fval, &result.bad_values)) lon = fval; + } else if (strcmp(key, "defer_clock") == 0) { + if (parse_bool(key, value, &bval, &result.bad_values)) defer_clock = bval ? 1 : 0; + } else if (strcmp(key, "gps_device") == 0) { + // Validate here rather than at open time: bad_values makes begin() refuse + // to start, which is the right answer for a device string the operator + // wrote and we cannot honour. Discovering it later would instead look + // like an absent GPS. + // Length is validated for the same reason. assign_string() truncates + // rather than failing, and a truncated /dev/serial/by-id/ path -- the form + // the templates recommend, and real ones run to 74 and 81 characters -- + // still parses as a device path. It just does not open, which reads as an + // absent receiver rather than as the config error it is. + if (strlen(value) >= LinuxGpsStream::DEVICE_MAX) { + printf("ERROR: meshcored.ini: gps_device is %zu characters; the maximum is %d\n", + strlen(value), (int) (LinuxGpsStream::DEVICE_MAX - 1)); + result.bad_values++; + } else if (LinuxGpsStream::parseDevice(value).valid) { + assign_string(key, value, &gps_device, &gps_device_owned, + LinuxGpsStream::DEVICE_MAX, &result.bad_values); + } else { + printf("ERROR: meshcored.ini: gps_device '%s' is not a device path or a " + "usable gpsd:// URL\n", value); + result.bad_values++; + } + } else if (strcmp(key, "gps_baud") == 0) { + int baud; + if (parse_gps_baud(value, &baud, &result.bad_values)) gps_baud = baud; + } else if (strcmp(key, "gps_en_pin") == 0) { + // -1/none means "no enable pin". Unlike the optional LoRa pins, which map + // it to RADIOLIB_NC, this field is a plain int the GPS code compares + // against -1, so spell that out rather than lean on the conversion. + if (strcmp(value, "-1") == 0 || strcasecmp(value, "none") == 0) { + gps_en_pin = -1; + } else if (parse_pin(key, value, PIN_REQUIRED, &pin, &result.bad_values)) { + gps_en_pin = (int) pin; + } } else { // Nothing below this chain consumes leftovers, so an unrecognised key is // a key that does nothing -- inert, and possibly just a key from a newer diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 8c2916d0b8..cad8ca9f87 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -43,6 +43,17 @@ class LinuxConfig { const char *admin_password = "password"; float lat = 0.0f; float lon = 0.0f; + const char *gps_device = ""; + int gps_baud = 9600; + int gps_en_pin = -1; + + // Transport-derived by default (see the setExternallyDisciplined() call in + // target.cpp): -1 leaves that default alone. An explicit `defer_clock = + // true/false` in meshcored.ini pins it to 1/0 regardless of gps_device's + // transport -- needed because gpsd is not the only way this process can end + // up racing something else that owns the clock (e.g. a serial gps_device + // plus CAP_SYS_TIME), and the transport string alone cannot see that. + int8_t defer_clock = -1; // Outcome of parsing meshcored.ini. Two failure kinds, kept apart because // they deserve opposite responses (see LinuxBoard::begin()): a value the @@ -107,29 +118,145 @@ class LinuxRTCClock : public mesh::RTCClock { // for the life of the daemon. bool _settime_warned = false; + // Set when the host clock is disciplined by something else (chrony, fed by + // gpsd). Then this is not merely futile but wrong to attempt. + bool _externally_disciplined = false; + + // False until this object has stepped the clock once. The first step is + // exempt from MAX_STEP_USECS below: a Pi with no RTC boots at whatever the + // last shutdown wrote (or at the epoch) and genuinely needs a jump of years. + bool _clock_set_once = false; + + // Latches the "refused an implausible timestamp" report, for the same reason + // _settime_warned is latched: a receiver stuck emitting date-less fixes would + // otherwise print this on every sync for the life of the daemon. + bool _rejected_warned = false; + + // Oldest timestamp worth installing. MicroNMEA takes the date from RMC while + // isValid() is satisfied by GGA alone, so a date-less fix leaves year 0 and + // DateTime(0,...).unixtime() lands on 2000-01-01 -- a plausible-looking value + // that is a parse artefact, not a time. Anything before this variant existed + // is one of those. A fixed constant rather than __DATE__: a build stamp makes + // the accepted range depend on when the binary was compiled, which is a + // surprising thing for a clock bound to do, and reproducible builds pin it + // anyway. + static constexpr uint32_t CLOCK_FLOOR_SECS = 1704067200u; // 2024-01-01T00:00:00Z + + // Largest step accepted after the first one. A node that has already been set + // once is within seconds of correct; a jump of more than a day is a bad fix + // or a hostile `time ` from a mesh peer, not a correction. + static constexpr int64_t MAX_STEP_USECS = (int64_t) 24 * 60 * 60 * 1000000; + + // Below this, slew instead of stepping -- see the comment in setCurrentTime(). + static constexpr int64_t SLEW_LIMIT_USECS = 1000000; + public: LinuxRTCClock() { } void begin() { } + + // Declare that the host clock belongs to another daemon. Both callers of + // setCurrentTime() take their timestamp from somewhere we should not be + // acting on in that case: GPS time sync duplicates what chrony already does + // better, and `clock sync` / `time ` carry a value supplied by a + // REMOTE MESH PEER. Refusing here makes "a LoRa peer cannot retime this + // host" true by configuration rather than by the accident of the shipped + // unit happening to lack CAP_SYS_TIME. + void setExternallyDisciplined(bool yes) { _externally_disciplined = yes; } uint32_t getCurrentTime() override { struct timeval tv; gettimeofday(&tv, NULL); return tv.tv_sec; } void setCurrentTime(uint32_t time) override { - struct timeval tv; - tv.tv_sec = time; - tv.tv_usec = 0; - if (settimeofday(&tv, NULL) == 0) { - // Deliberately not deduped like the warning below: this line's whole - // value is showing *every* time GPS/mesh sync steps the clock, so a - // clock fighting something else on the host (NTP, a process with - // CAP_SYS_TIME) is visible in the journal instead of silently winning - // or losing against it. - printf("NOTE: system clock set to %u by mesh/GPS time sync.\n", (unsigned) time); + if (_externally_disciplined) { + if (!_settime_warned) { + _settime_warned = true; + printf("NOTE: not setting the system clock -- another clock source owns it\n" + " (gps_device names gpsd, or defer_clock = true in meshcored.ini).\n" + " GPS and mesh time sync are ignored here by design; getCurrentTime()\n" + " still reads the host clock.\n"); + } + return; + } + + // Everything below exists because on this platform CLOCK_REALTIME is also + // the timebase millis() is derived from: ardulinux computes millis() as + // gettimeofday() minus a start offset captured once (cores/ardulinux/linux/ + // millis.cpp). So a settimeofday() does not merely retime the host, it + // shifts millis() by the same delta -- and every deadline already in flight + // in Dispatcher::millisHasNowPassed(), the CAD retry and the delayed-inbound + // queue moves with it. A backwards step un-expires them; a step back past + // the start offset makes `millis() - startMsec` underflow and every deadline + // comparison in the tree garbage. This is a safety bound on that, not a + // time discipline: chrony is the right answer, hence defer_clock above. + struct timeval now; + gettimeofday(&now, NULL); + + if (time < CLOCK_FLOOR_SECS) { + // MicroNMEA's year comes from RMC while isValid() can be satisfied by GGA + // alone, so a date-less fix yields year 0 -> 2000-01-01, and time_valid + // gates on fix age rather than on the date. Refusing beats a 25-year step + // backwards. + if (!_rejected_warned) { + _rejected_warned = true; + printf("WARNING: refusing to set the system clock to %u -- implausibly old\n" + " (before %u). A GPS fix without a date reads as year 2000.\n", + (unsigned) time, (unsigned) CLOCK_FLOOR_SECS); + } + return; + } + + int64_t delta_us = ((int64_t) time - (int64_t) now.tv_sec) * 1000000 - (int64_t) now.tv_usec; + int64_t abs_us = delta_us < 0 ? -delta_us : delta_us; + + if (_clock_set_once && abs_us > MAX_STEP_USECS) { + // Past the first set this node is within seconds of correct, so a jump of + // more than a day is a bad fix or a `time ` from a mesh peer. + if (!_rejected_warned) { + _rejected_warned = true; + printf("WARNING: refusing to set the system clock to %u -- more than 24 h\n" + " from the current time. Restart meshcored if this is genuine.\n", + (unsigned) time); + } return; } + if (abs_us < SLEW_LIMIT_USECS) { + // The common case, and the one that must not step. NMEA names a second + // that has already begun -- measured 0.193-0.384 s late on the Waveshare + // HAT, see "Disciplining the host clock from GNSS" in README.md -- so + // MicroNMEALocationProvider's re-sync every TIME_SYNC_INTERVAL is + // systematically a fraction of a second behind. Stepping would drag the + // clock, and millis() with it, backwards by that fraction 48 times a day. + // adjtime() spreads the correction instead, so millis() only ever runs + // slightly slow or fast. + struct timeval adj; + adj.tv_sec = 0; + adj.tv_usec = (suseconds_t) delta_us; + if (adjtime(&adj, NULL) == 0) { + _clock_set_once = true; + return; + } + // Fall through to the CAP_SYS_TIME warning below: adjtime() needs the + // same capability settimeofday() does, so this failing means both would. + } else { + struct timeval tv; + tv.tv_sec = time; + tv.tv_usec = 0; + if (settimeofday(&tv, NULL) == 0) { + _clock_set_once = true; + // Deliberately not latched like the warnings around it: this line's + // whole value is showing *every* time GPS/mesh sync steps the clock, so a + // clock fighting something else outside the gpsd case above -- a + // serial gps_device plus CAP_SYS_TIME, see defer_clock in + // meshcored.ini -- is visible in the journal instead of silently + // winning or losing against it. + printf("NOTE: system clock stepped to %u by mesh/GPS time sync.\n", (unsigned) time); + return; + } + } + // Unlike an MCU, this is the whole host's clock, and setting it needs // CAP_SYS_TIME. The shipped unit runs as an unprivileged `meshcore` user // with NoNewPrivileges=yes, so it does not have it and this always fails -- diff --git a/variants/linux/LinuxGpsStream.cpp b/variants/linux/LinuxGpsStream.cpp new file mode 100644 index 0000000000..f14ff37aac --- /dev/null +++ b/variants/linux/LinuxGpsStream.cpp @@ -0,0 +1,385 @@ +#include "LinuxGpsStream.h" +#include // MESH_DEBUG_PRINTLN +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // millis() + +static const char GPSD_SCHEME[] = "gpsd://"; +static const char GPSD_DEFAULT_HOST[] = "127.0.0.1"; +static const int GPSD_DEFAULT_PORT = 2947; + +LinuxGpsStream::Target LinuxGpsStream::parseDevice(const char* device) { + Target t; + + if (device == NULL || device[0] == '\0') { + t.transport = NO_SOURCE; + t.valid = true; + return t; + } + + if (strncmp(device, GPSD_SCHEME, sizeof(GPSD_SCHEME) - 1) != 0) { + t.transport = SERIAL_DEVICE; + t.valid = true; + return t; + } + + t.transport = GPSD_SOCKET; + const char* rest = device + sizeof(GPSD_SCHEME) - 1; + + // Split host from port on the single permitted ':'. More than one means an + // IPv6 literal (or a typo); either way host:port cannot be recovered + // unambiguously, so reject rather than guess. + const char* colon = strchr(rest, ':'); + size_t host_len = colon ? (size_t)(colon - rest) : strlen(rest); + + if (colon != NULL) { + if (strchr(colon + 1, ':') != NULL) return t; // invalid: multiple colons + const char* p = colon + 1; + if (*p == '\0') return t; // invalid: "host:" + char* endp = NULL; + long port = strtol(p, &endp, 10); + if (endp == NULL || *endp != '\0') return t; // invalid: non-numeric + if (port < 1 || port > 65535) return t; // invalid: out of range + t.port = (int) port; + } else { + t.port = GPSD_DEFAULT_PORT; + } + + if (host_len >= sizeof(t.host)) return t; // invalid: host too long + if (host_len == 0) { + strcpy(t.host, GPSD_DEFAULT_HOST); + } else { + memcpy(t.host, rest, host_len); + t.host[host_len] = '\0'; + } + + t.valid = true; + return t; +} + +// LinuxBoard::reboot() re-execs this process image, and execv() keeps every +// descriptor that is not close-on-exec. Neither of the ones opened here is +// reachable from the new image, so each reboot would leak a socket to gpsd or +// an open handle on the GPS tty for the life of the rebooted daemon. +// +// fcntl() rather than SOCK_CLOEXEC/O_CLOEXEC: this file also compiles for the +// native test build on macOS, and one pattern is easier to check than two. The +// window before the flag is set is harmless -- the only exec is this process's +// own reboot(), which cannot run part-way through these calls. +static void set_cloexec(int fd) { + int fl = fcntl(fd, F_GETFD, 0); + if (fl != -1) fcntl(fd, F_SETFD, fl | FD_CLOEXEC); +} + +// Map an integer baud to the matching termios Bxxxx constant. +// Unknown values log and fall back to B9600. +static speed_t baud_to_speed(int baud) { + switch (baud) { + case 4800: return B4800; + case 9600: return B9600; + case 19200: return B19200; + case 38400: return B38400; + case 57600: return B57600; + case 115200: return B115200; + default: + MESH_DEBUG_PRINTLN("LinuxGpsStream: unsupported baud %d, using 9600", baud); + return B9600; + } +} + +// gpsd streams NMEA verbatim when asked to. Reading NMEA back out of gpsd is +// what lets MicroNMEALocationProvider stay untouched -- the alternative, gpsd's +// JSON TPV/SKY protocol, would mean teaching the shared provider a second wire +// format for no gain. +static const char WATCH_CMD[] = "?WATCH={\"enable\":true,\"nmea\":true}\n"; + +static const uint32_t RETRY_MIN_MS = 1000; +static const uint32_t RETRY_MAX_MS = 30000; +static const uint32_t CONNECT_LIMIT_MS = 5000; +static const uint32_t READ_GAP_MS = 5000; + +bool LinuxGpsStream::begin(const char* device, int baud) { + end(); // idempotent + + Target t = parseDevice(device); + if (!t.valid) { + printf("ERROR: gps_device '%s' is not a usable device or gpsd:// URL\n", device); + return false; + } + + _transport = t.transport; + if (_transport == NO_SOURCE) return false; + if (_transport == SERIAL_DEVICE) { + // Remembered so serviceSerial() can reopen the device after it goes away. + // LinuxConfig::load() rejects anything longer than DEVICE_MAX, so this + // cannot truncate a value that came from meshcored.ini. + snprintf(_dev_path, sizeof _dev_path, "%s", device); + _dev_baud = baud; + return openSerial(device, baud); + } + + if (!resolveGpsd(t)) { + _transport = NO_SOURCE; + return false; + } + startConnect(); // may not complete now; rawReadByte() finishes it + return true; +} + +// Resolve once, at startup, where blocking is already accepted. Reconnects then +// reuse the cached address -- getaddrinfo() on a read path could stall the main +// loop for the length of a DNS timeout. +bool LinuxGpsStream::resolveGpsd(const Target& t) { + char port[8]; + snprintf(port, sizeof port, "%d", t.port); + + struct addrinfo hints; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo* res = NULL; + int rc = getaddrinfo(t.host, port, &hints, &res); + if (rc != 0 || res == NULL) { + printf("ERROR: cannot resolve gpsd host '%s': %s\n", t.host, gai_strerror(rc)); + return false; + } + memcpy(&_addr, res->ai_addr, res->ai_addrlen); + _addr_len = res->ai_addrlen; + freeaddrinfo(res); + return true; +} + +void LinuxGpsStream::startConnect() { + // socket() + fcntl() rather than SOCK_NONBLOCK: the native test build runs on + // the host, which may not be Linux. + _fd = socket(_addr.ss_family, SOCK_STREAM, 0); + if (_fd < 0) { dropConnection(); return; } + set_cloexec(_fd); + fcntl(_fd, F_SETFL, fcntl(_fd, F_GETFL, 0) | O_NONBLOCK); + + int one = 1; + setsockopt(_fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + + _connect_at_ms = millis(); + if (::connect(_fd, (struct sockaddr*)&_addr, _addr_len) == 0) { + _state = GPSD_CONNECTING; // finishConnect() still has to send WATCH + return; + } + if (errno == EINPROGRESS) { _state = GPSD_CONNECTING; return; } + dropConnection(); +} + +void LinuxGpsStream::finishConnect() { + struct pollfd p; + p.fd = _fd; p.events = POLLOUT; p.revents = 0; + if (poll(&p, 1, 0) <= 0) { + // Still in flight. Give up eventually rather than sit here forever. + if ((uint32_t)(millis() - _connect_at_ms) > CONNECT_LIMIT_MS) dropConnection(); + return; + } + + int err = 0; + socklen_t len = sizeof err; + if (getsockopt(_fd, SOL_SOCKET, SO_ERROR, &err, &len) != 0 || err != 0) { + dropConnection(); + return; + } + + size_t sent = 0; + const size_t want = sizeof(WATCH_CMD) - 1; + while (sent < want) { + ssize_t n = ::write(_fd, WATCH_CMD + sent, want - sent); + if (n > 0) { sent += (size_t) n; continue; } + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break; + dropConnection(); + return; + } + if (sent < want) { + // A freshly-connected non-blocking socket's send buffer would not take + // the whole WATCH command in one write(). Nothing retries the remainder + // and the read-gap logic cannot detect the resulting silence, so this + // must not be treated as READY -- drop and let startConnect() retry the + // handshake from scratch. + dropConnection(); + return; + } + + _state = GPSD_READY; + _retry_delay_ms = 0; + MESH_DEBUG_PRINTLN("LinuxGpsStream: connected to gpsd"); +} + +// Close and arm the backoff. Doubling from 1 s to a 30 s ceiling: a gpsd that +// is simply not running yet should be picked up quickly, but a permanently +// absent one must not be retried in a tight loop for the life of the daemon. +// Shared with the serial path, which wants exactly the same schedule for a +// device node that has gone away. +void LinuxGpsStream::dropConnection() { + if (_fd >= 0) { close(_fd); _fd = -1; } + clearPeek(); + _state = GPSD_IDLE; + _retry_delay_ms = _retry_delay_ms == 0 ? RETRY_MIN_MS + : (_retry_delay_ms * 2 > RETRY_MAX_MS ? RETRY_MAX_MS : _retry_delay_ms * 2); + _retry_at_ms = millis() + _retry_delay_ms; +} + +void LinuxGpsStream::serviceGpsd() { + uint32_t now = millis(); + + // EnvironmentSensorManager drains this stream only while gps_active is true, + // so a `gps off` stops the reads entirely. A tty just queues undrained bytes + // (serviceSerialGap() below flushes those); a socket's receive window fills + // instead, and gpsd drops clients it cannot write to. Dropping it ourselves + // is both the polite move and the one that stops `gps on` from replaying a + // backlog of stale NMEA as though it were current. + if (_state == GPSD_READY && _last_read_ms != 0 && + (uint32_t)(now - _last_read_ms) > READ_GAP_MS) { + dropConnection(); + _retry_at_ms = now; // deliberate: a fresh start, not a failure to back off from + _retry_delay_ms = 0; + } + _last_read_ms = now; + + if (_state == GPSD_IDLE) { + if ((int32_t)(now - _retry_at_ms) >= 0) startConnect(); + return; + } + if (_state == GPSD_CONNECTING) finishConnect(); +} + +// The serial path's counterpart to serviceGpsd(): reopen a device that went +// away, and flush a backlog that piled up while nothing was reading. +// +// Reopen -- a USB receiver unplugged (or a hub reset, or a CDC-ACM device +// re-enumerating) makes read() return EIO or ENXIO forever. Without this the fd +// stays open and the GPS is silently dead until meshcored restarts, which is +// reachable: the ini templates recommend /dev/ttyACM0 and /dev/ttyUSB0, and a +// replugged device comes back under the same name (or the same by-id path). +// +// Flush -- `gps off` stops EnvironmentSensorManager from draining this stream, +// so NMEA sentences pile up in the tty's kernel input queue (~4 KB) while +// nothing reads them. The gpsd path drops and re-establishes the connection; +// flushing is the tty equivalent, so `gps on` sees fresh data instead of +// replaying the backlog as though it were current. +void LinuxGpsStream::serviceSerial() { + uint32_t now = millis(); + + if (_fd < 0) { + if ((int32_t)(now - _retry_at_ms) < 0) return; + if (openSerial(_dev_path, _dev_baud)) { + printf("NOTE: GPS device %s reopened.\n", _dev_path); + _retry_delay_ms = 0; + _last_read_ms = 0; // nothing has been read since; do not flush on the next call + } else { + dropConnection(); // openSerial() left _fd at -1; this re-arms the backoff + } + return; + } + + if (_last_read_ms != 0 && (uint32_t)(now - _last_read_ms) > READ_GAP_MS) { + tcflush(_fd, TCIFLUSH); + } + _last_read_ms = now; +} + +bool LinuxGpsStream::isPresent() const { + if (_transport == SERIAL_DEVICE) return _fd >= 0; + return _transport == GPSD_SOCKET; +} + +bool LinuxGpsStream::openSerial(const char* path, int baud) { + _fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK); + if (_fd < 0) { + MESH_DEBUG_PRINTLN("LinuxGpsStream: cannot open %s: %s", path, strerror(errno)); + return false; + } + set_cloexec(_fd); + + struct termios tio; + if (tcgetattr(_fd, &tio) != 0) { + MESH_DEBUG_PRINTLN("LinuxGpsStream: tcgetattr(%s) failed: %s", path, strerror(errno)); + close(_fd); + _fd = -1; + return false; + } + + cfmakeraw(&tio); + speed_t sp = baud_to_speed(baud); + cfsetispeed(&tio, sp); + cfsetospeed(&tio, sp); + tio.c_cflag |= (CLOCAL | CREAD); // ignore modem ctrl lines, enable receiver + tio.c_cc[VMIN] = 0; // non-blocking read + tio.c_cc[VTIME] = 0; + + if (tcsetattr(_fd, TCSANOW, &tio) != 0) { + MESH_DEBUG_PRINTLN("LinuxGpsStream: tcsetattr(%s) failed: %s", path, strerror(errno)); + close(_fd); + _fd = -1; + return false; + } + + tcflush(_fd, TCIFLUSH); + MESH_DEBUG_PRINTLN("LinuxGpsStream: opened %s @ %d", path, baud); + return true; +} + +void LinuxGpsStream::end() { + if (_fd >= 0) { close(_fd); _fd = -1; } + clearPeek(); + _transport = NO_SOURCE; + _dev_path[0] = '\0'; + _dev_baud = 0; + _state = GPSD_IDLE; + _retry_delay_ms = 0; + _retry_at_ms = 0; + _last_read_ms = 0; +} + +int LinuxGpsStream::rawReadByte() { + if (_transport == GPSD_SOCKET) serviceGpsd(); + else if (_transport == SERIAL_DEVICE) serviceSerial(); + if (_fd < 0 || _state == GPSD_CONNECTING) return -1; + + uint8_t b; + ssize_t n = ::read(_fd, &b, 1); + if (n == 1) return b; + + // EINTR is a signal interrupting the call, not a broken source -- benign, + // same as EAGAIN/EWOULDBLOCK. Any other error means the source is gone. + bool fatal = n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR; + + if (_transport == GPSD_SOCKET) { + // n == 0 is the peer hanging up -- gpsd restarted, or dropped a client it + // could not write to. On a tty n == 0 means "no data right now" instead + // (VMIN and VTIME are both 0), so only the socket path reads it that way. + if (n == 0 || fatal) dropConnection(); + } else if (_transport == SERIAL_DEVICE && fatal) { + // Once per disappearance, not once per read: the fd is closed here, so the + // next report can only come after a successful reopen and a fresh failure. + printf("WARNING: GPS device %s read failed (%s); closing and retrying.\n", + _dev_path, strerror(errno)); + dropConnection(); + } + return -1; // n == 0 (no data) or -1/EAGAIN/EINTR +} + +size_t LinuxGpsStream::write(uint8_t c) { + // gpsd's socket speaks JSON commands, not raw NMEA, so forwarding a sentence + // there would be wrong rather than merely useless. Nothing calls this today: + // LocationProvider::sendSentence() has no callers anywhere in the tree. + if (_transport == GPSD_SOCKET) return 0; + if (_fd < 0) return 0; + ssize_t n = ::write(_fd, &c, 1); + return n == 1 ? 1 : 0; +} diff --git a/variants/linux/LinuxGpsStream.h b/variants/linux/LinuxGpsStream.h new file mode 100644 index 0000000000..362282cc72 --- /dev/null +++ b/variants/linux/LinuxGpsStream.h @@ -0,0 +1,118 @@ +#pragma once +#include "PeekableStream.h" +#include +#include +#include + +// GPS byte source for the ArduLinux (Linux) build. +// +// ardulinux has no hardware UART / Serial1.setPath(); a GPS on Linux is either +// a /dev/tty* character device or, where gpsd owns the receiver, a socket. This +// opens whichever the config names and presents it as an Arduino Stream, so the +// shared MicroNMEALocationProvider reads NMEA through it unchanged. +// Modelled on LinuxConsole's fd wrapping. +class LinuxGpsStream : public PeekableStream { +public: + enum Transport { NO_SOURCE, SERIAL_DEVICE, GPSD_SOCKET }; + + // Buffer size for a gps_device string, terminator included. Also the limit + // LinuxConfig::load() validates against, so the two cannot drift: the stable + // names the ini templates recommend are long -- real /dev/serial/by-id/ paths + // measure 74 and 81 characters for a u-blox and a Prolific adapter. + static constexpr size_t DEVICE_MAX = 256; + + LinuxGpsStream() = default; + + // The open descriptor is this object's whole state, so it needs the same + // treatment PtyConsole gives its own: closed on destruction, and never + // duplicated into a second owner that would close it twice. + ~LinuxGpsStream() { end(); } + LinuxGpsStream(const LinuxGpsStream&) = delete; + LinuxGpsStream& operator=(const LinuxGpsStream&) = delete; + + // Parsed gps_device value. Public because both begin() and the ini + // validation in LinuxBoard need to ask "is this string usable?" -- the + // validator has to answer before any device is opened. + struct Target { + Transport transport = NO_SOURCE; + char host[64] = ""; // GPSD_SOCKET only + int port = 0; // GPSD_SOCKET only + bool valid = false; + }; + + // Parse a gps_device value. Never touches hardware. + // "" -> NO_SOURCE (GPS disabled), valid + // "gpsd://[host][:port]" -> GPSD_SOCKET defaults 127.0.0.1:2947 + // anything else -> SERIAL_DEVICE (a /dev path) + // The enum members avoid the bare names NONE/SERIAL/GPSD: ArduinoCore-API's + // Common.h defines SERIAL as a macro, which silently swallows an enumerator. + // valid == false means the operator wrote a gpsd:// URL that cannot be + // honoured; the caller counts it as a bad value and refuses to start. + static Target parseDevice(const char* device); + + // Open whatever `device` names. `baud` applies to a serial device only. + // Returns false on a device string that cannot be honoured, or a serial + // device that would not open. A configured-but-unreachable gpsd returns + // true: the socket is retried, and the daemon must not treat gpsd starting + // late as "no GPS". + bool begin(const char* device, int baud); + void end(); + + // "There is a GPS to talk to." + // SERIAL_DEVICE -- the fd is open. A /dev/tty* that would not open is a + // permanent failure for this boot, and claiming a GPS + // would be a lie. Also false in the window after the + // device disappeared and before serviceSerial() has it + // open again, which is the same answer for the same + // reason. + // GPSD_SOCKET -- a source is configured, connected or not. gpsd may + // legitimately start after meshcored, and initBasicGPS() + // only asks once. + bool isPresent() const; + + Transport transport() const { return _transport; } + + size_t write(uint8_t c) override; + using Print::write; + +protected: + int rawReadByte() override; // one raw byte from the fd, or -1 + + // The open GPS descriptor, or -1. Protected, not public: tests need to + // inspect it (that it is close-on-exec), while any poll-based idle loop must + // still have no way to reach it. EnvironmentSensorManager drains this stream + // only while GPS is active, but the module streams regardless, so a + // descriptor registered with a poll() loop that nothing drains would sit + // permanently readable and turn that loop into a busy spin. + int fd() const { return _fd; } + +private: + // gpsd connection state. CONNECTING exists because connect() must not block + // the main loop: it is started non-blocking and completed on a later read. + enum GpsdState { GPSD_IDLE, GPSD_CONNECTING, GPSD_READY }; + + bool openSerial(const char* path, int baud); + bool resolveGpsd(const Target& t); + void startConnect(); + void finishConnect(); + void dropConnection(); + void serviceGpsd(); + void serviceSerial(); + + int _fd = -1; + Transport _transport = NO_SOURCE; + + // SERIAL_DEVICE only: what begin() was given, kept so the device can be + // reopened after it disappears (a USB receiver unplugged and replugged). + char _dev_path[DEVICE_MAX] = ""; + int _dev_baud = 0; + + struct sockaddr_storage _addr = {}; + socklen_t _addr_len = 0; + + GpsdState _state = GPSD_IDLE; + uint32_t _retry_at_ms = 0; // earliest next connect attempt + uint32_t _retry_delay_ms = 0; // current backoff, 0 until the first failure + uint32_t _connect_at_ms = 0; // when the in-flight connect started + uint32_t _last_read_ms = 0; // for the read-gap reconnect (gpsd) / flush (serial) +}; diff --git a/variants/linux/PeekableStream.h b/variants/linux/PeekableStream.h new file mode 100644 index 0000000000..206f2d3265 --- /dev/null +++ b/variants/linux/PeekableStream.h @@ -0,0 +1,40 @@ +#pragma once +#include + +// Arduino Stream over a non-blocking byte source. +// +// available(), peek() and read() all have to answer "is there a byte?" without +// consuming it, but a non-blocking descriptor only answers that by reading. One +// byte of lookahead bridges the two, and it is the same bridge for every such +// source -- so subclasses supply only rawReadByte() and keep the buffering, +// which is easy to get subtly inconsistent, in one place. +class PeekableStream : public Stream { +public: + int available() override { return fill() >= 0 ? 1 : 0; } + int peek() override { return fill(); } + + int read() override { + int c = fill(); + _peek = -1; + return c; + } + + using Print::write; + +protected: + // The next byte from the underlying source, or -1 if none is available right + // now. Must not block. + virtual int rawReadByte() = 0; + + // Discard any buffered lookahead. Call when the source is closed or replaced, + // so a byte read from the old one cannot surface from the new. + void clearPeek() { _peek = -1; } + +private: + int fill() { + if (_peek < 0) _peek = rawReadByte(); + return _peek; + } + + int _peek = -1; // one-byte lookahead, or -1 +}; diff --git a/variants/linux/README.md b/variants/linux/README.md index 0c8912bc0b..75e6204424 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -122,6 +122,10 @@ Key settings: | `advert_name` | `"Linux Repeater"` | Node name, first-run default only | | `admin_password` | `"password"` | Admin password, **change this**, first-run default only | | `lat` / `lon` | `0.0` | GPS coordinates for advertisement, first-run default only | +| `gps_device` | *(empty)* | Serial path (`/dev/ttyACM0`) or `gpsd://[host][:port]` (default `127.0.0.1:2947`). Empty disables GPS. See [GPS](#gps) | +| `gps_baud` | `9600` | Serial `gps_device` only; one of 4800/9600/19200/38400/57600/115200. Ignored for `gpsd://` — gpsd owns the port | +| `gps_en_pin` | `-1` | GPIO held HIGH to wake a receiver that boots in standby (e.g. the L76K STANDBY line). `-1` = none. Leave unset with a `gpsd://` source — see [Keeping GNSS up without meshcored](#keeping-gnss-up-without-meshcored) | +| `defer_clock` | *(unset)* | Override whether meshcored sets the system clock from GPS/mesh time sync. Unset: on exactly when `gps_device` is `gpsd://` (gpsd already owns the clock). `true`: never set it — e.g. a serial `gps_device` whose NMEA is separately fed to chrony. `false`: always attempt to set it, even against a `gpsd://` device | Comments (`#`, `;`), blank lines and `[section]` headers are ignored. Boolean settings (`dio2_as_rf_switch`, `rx_boosted_gain`) accept `1`/`0`, @@ -189,7 +193,7 @@ Create the group, add yourself to it, and install the rules: ```sh sudo groupadd -f -r meshcore -sudo usermod -aG meshcore "$USER" # log out/in afterwards for this to take effect +sudo usermod -aG meshcore,dialout "$USER" # dialout: serial GPS; log out/in afterwards sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger ``` @@ -203,7 +207,8 @@ ls -l /dev/gpiochip* /dev/spidev* # → crw-rw---- root meshcore Your current login session won't pick up the new group until you log out and back in. To use it immediately in one shell, prefix the command with `sg meshcore -c '…'`. (On Raspberry Pi OS you can instead use the built-in -`spi`/`gpio` groups: `sudo usermod -aG spi,gpio $USER`.) +`spi`/`gpio` groups: `sudo usermod -aG spi,gpio,dialout $USER`; `dialout` is only +needed for a serial GPS, see [GPS](#gps).) ### 4. Run @@ -235,6 +240,7 @@ sudo install -m 644 variants/linux/meshcored.service /etc/systemd/system/ sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger sudo useradd -r -g meshcore -s /sbin/nologin meshcore # -g: reuse the existing meshcore group (its udev rules grant device access) +sudo usermod -aG dialout meshcore # only for a serial GPS, see GPS below sudo chmod 640 /etc/meshcored/meshcored.ini sudo chown root:meshcore /etc/meshcored/meshcored.ini sudo systemctl daemon-reload @@ -279,8 +285,243 @@ 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. +## GPS + +With `gps_device` configured, the standard MeshCore GPS commands work through +the CLI: + +| Command | Effect | +|---------|--------| +| `gps` | Status: on/off, active/deactivated, fix/no-fix, satellite count | +| `gps on` / `gps off` | Enable/disable GPS reading and location telemetry | +| `gps sync` | Force a time re-sync from GPS | +| `gps setloc` | Save the current fix as the node's advertised location | +| `gps advert none\|prefs\|share` | Control whether location is advertised | + +At the 1 s read interval, the two `lat …` debug lines printed on every read +dominate the journal on a node with a fix. + +### Serial GPS + +The daemon opens the device directly and holds it for its whole life. It needs +read access — USB units and the Pi's own UART are usually `dialout`-owned, which +the `dialout` membership from step 3 covers (`sudo usermod -aG dialout meshcore` +for the service user). + +Some receivers boot into standby and stay silent until an enable line is driven +high. The L76K on the Waveshare LoRaWAN/GNSS HAT is one: set `gps_en_pin` to its +STANDBY GPIO (4 on that HAT) and the daemon holds it high from startup. + +### Disciplining the host clock from GNSS + +Holding the device exclusively locks out `gpsd`, and through it `chrony`. On a +Linux node that matters more than on an MCU: the node's clock is the whole host's +clock, and a host with no network and no RTC otherwise boots with a bogus one. + +It also matters to the daemon's own timers. ArduLinux derives `millis()` from +`CLOCK_REALTIME` (`gettimeofday()` minus a start offset captured once), so every +step of the system clock shifts `millis()` by the same amount and moves every +mesh deadline already in flight with it. `LinuxRTCClock` therefore refuses a +timestamp older than 2024, refuses a jump of more than 24 h once the clock has +been set, and slews sub-second corrections with `adjtime()` instead of stepping +— but letting chrony own the clock, below, is the better answer. + +Point `gps_device` at gpsd and the device becomes gpsd's: + +```ini +gps_device = gpsd:// # 127.0.0.1:2947 +# gps_en_pin stays unset -- the host holds the line, see below +``` + +The daemon reconnects to gpsd on its own with backoff, so start order does not +matter and restarting gpsd underneath a running node is safe. It also stops +trying to set the system clock — chrony owns it. That also means a `clock sync` / +`time ` carrying a timestamp from a remote mesh peer cannot move a +general-purpose host's clock. + +On the host: + +```sh +sudo apt install gpsd gpsd-clients chrony +``` + +`/etc/default/gpsd`: + +```sh +DEVICES="/dev/ttyAMA0" # /dev/ttyS0 on a Pi whose PL011 is not the GPS UART +GPSD_OPTIONS="-n" +``` + +`-n` keeps gpsd reading the receiver after its last client disconnects, which +chrony's SHM refclock needs — it is not a gpsd socket client. It says nothing +about *starting*, which is the separate trap covered in +[Keeping GNSS up without meshcored](#keeping-gnss-up-without-meshcored). + +`/etc/chrony/chrony.conf`: + +``` +refclock SHM 0 refid GPS offset 0.307 delay 0.2 +``` + +**The `offset` is mandatory and board-specific.** NMEA without PPS arrives some +way after the second it describes, so the refclock reads consistently late; left +uncorrected, chrony marks it a falseticker (`#x` in `chronyc sources`) and ignores +it. Start at `0.0`, read the steady-state figure, and enter it as a positive +number — a positive offset cancels a positive reported error, and getting the sign +backwards doubles it: + +```sh +chronyc sources # "#x GPS ... +307ms[ +307ms]" -> offset 0.307 +``` + +The value is stable within a session (sd ~5 ms) but is not a constant of the +board: it tracks how long the receiver takes to start and clock out its sentence +burst, so it moves with the sentence set and the baud rate. On this HAT it has +been seen at 0.193 s, 0.307 s and 0.384 s with nothing touched. Expect to +re-check it — and note that +[adding PPS](docs/gnss-pps-hardware.md#handing-pps-to-chrony) inverts how to +tune it. + +Verify with `chronyc sources` (a `GPS` line that is no longer `#x`) and +the `gps` CLI command (fix and satellite count). + +With a network present chrony will usually still prefer a good NTP server — +NMEA-only GPS is worth about ±100 ms against a stratum-1 peer's ±30 ms, so `#-` +rather than `#*` is correct. To prove GPS can hold the clock alone, take the +network sources away: + +```sh +sudo chronyc offline # refclocks are unaffected +# ~4 minutes later, once the NTP peers age out: +chronyc tracking # Reference ID : 47505300 (GPS), Stratum : 1 +sudo chronyc online +``` + +`gps off` stops the reads rather than closing the socket, so gpsd is left holding +a client that has stopped draining. No stale NMEA is acted on, though: the first +read after a gap of more than 5 s reconnects, so `gps on` resumes from live +sentences. + +### PPS on the Waveshare LoRaWAN/GNSS HAT + +Needs a hardware mod. The L76K emits a pulse-per-second signal, but the HAT +routes it only to indicator LED `L_PPS1` through R19 (510R) — no `PPS` net reaches +the Raspberry Interface block, and no unpopulated jumper or DNF resistor would +route it. One wire fixes that. + +It is worth soldering: NMEA-only is worth about ±100 ms, while kernel PPS on a Pi +settles under a microsecond, bounded by interrupt latency rather than by the +receiver. + +PPS is a precision layer on top of gpsd, never a replacement: it says when a +second begins, not which second it is. Keep the NMEA refclock. + +The mod itself — identifying R19, sizing the series resistor, landing the wire on +a Pi header pin, the device-tree overlay, and handing the pulse to chrony (plus +serving the resulting stratum 1 to the LAN) — is in +[docs/gnss-pps-hardware.md](docs/gnss-pps-hardware.md). + +### Keeping GNSS up without meshcored + +Once chrony takes its time from the receiver the dependency runs the wrong way +round: the host's clock rests on a stack that by default only works while the mesh +daemon happens to be running. Two things cause that, and a node in this state +looks perfect until meshcored stops. Both matter more with PPS, because `lock GPS` +means PPS cannot number its own seconds — no gpsd is not "PPS without NMEA +labelling", it is no stratum 1 at all. + +**1. gpsd does not start until something connects to it.** Debian ships gpsd +socket-activated: `gpsd.socket` is enabled and `gpsd.service` is not, so the +daemon is spawned by the first client on port 2947. Where meshcored is the only +gpsd client, the entire GNSS chain — device, SHM, chrony's stratum 1 — is +conditional on it connecting, and a meshcored held down by a bad `meshcored.ini` +takes the host's clock with it. + +```sh +systemctl is-enabled gpsd.service gpsd.socket # the trap: "disabled" / "enabled" +sudo systemctl enable --now gpsd.service # [Install] pulls gpsd.socket in via Also= +``` + +Then make a crash self-healing, since there is no longer a client whose reconnect +would restart it: + +```ini +# /etc/systemd/system/gpsd.service.d/resilience.conf +[Service] +Restart=on-failure +RestartSec=5 +``` + +**2. The GPS enable pin is unowned whenever meshcored is not running.** +`gps_en_pin` is held for the daemon's lifetime and no longer. In practice the pad +keeps its last state and the receiver stays awake, so this is not an outage +waiting to happen — but what holds the L76K awake is then a pull-up before the +first run and a leftover output level after the last, neither of which is +configuration, and an unowned line is also unprotected against another consumer +claiming it and driving it low. + +Hand the line to the kernel instead, in `/boot/firmware/config.txt`, and leave +`gps_en_pin` unset: + +``` +dtoverlay=gpio-hog,gpio=4 +``` + +A hogged GPIO is driven for the whole boot and, in the overlay's own words, "not +available to other drivers or for gpioset/gpioget". + +> **Pass `gpio=4` explicitly.** The overlay's default is **26**, which on this +> board is the PPS input — `dtoverlay=gpio-hog` bare would hog the pulse line and +> break the thing the hog was added to protect. + +Not `gpio=4=op,dh`: that firmware directive sets an initial pad state before the +kernel starts, it does not take ownership, so the line stays free for anything to +claim and drop. Only the hog makes the pin state both declared and defended. + +meshcored's own claim then fails and says so, which is the correct outcome rather +than a fault (setting `gps_en_pin` alongside a `gpsd://` device draws a warning +pointing here too): + +``` +WARNING: could not claim GPS enable pin 4; GPS may stay asleep + (expected if the host holds this line, e.g. a GPIO hog) +``` + +Two different failures, so two checks. That GNSS survives the mesh daemon: + +```sh +sudo systemctl stop meshcored +sleep 30 +chronyc tracking # want: Reference ID 50505300 (PPS), Stratum 1, unchanged +cgps -s # want: still a fix -- gpsd is holding the receiver alone +sudo systemctl start meshcored +``` + +And that it does not need one to *begin* with, which only shows itself across a +boot — this is the one that bites, because gpsd left to socket activation looks +identical to a correct node for as long as meshcored keeps connecting: + +```sh +systemctl is-enabled gpsd.service # want: enabled (not "disabled" + an enabled socket) +sudo journalctl -b -u gpsd | head -3 # want: started at boot, before any client connected +``` + +### Two HAT quirks that are easy to get wrong + +- **GPIO 4 is load-bearing.** R13 (marked `NC/0R`) is fitted, so driving GPIO 4 + low stops NMEA dead. The pin reads high when undriven only because of the SoC's + default pull-up on GPIO 0–8, which is not something to rely on. Hold it + deliberately: `gps_en_pin = 4` for as long as meshcored runs, or + `dtoverlay=gpio-hog,gpio=4` for the whole boot — with a `gpsd://` source, use + the hog. +- **Switch S1 drives the same transistor in parallel with GPIO 4.** If the GPS + will not sleep, that switch is why. `FORCE_ON` is pushbutton K1, not a GPIO — + GPIO 17 is unconnected here, despite `DEV_FORCE 17` in Waveshare's sample code + for the standalone L76X module. + ## Known Gaps / TODO +- **A `gpsd://` host cannot be a bare IPv6 literal**, because `host:port` cannot be split from one unambiguously. Such a value is rejected as an invalid `gps_device` rather than silently misparsed; use a hostname, an IPv4 address, or the `127.0.0.1` default that a local gpsd needs anyway. - **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. diff --git a/variants/linux/docs/gnss-pps-hardware.md b/variants/linux/docs/gnss-pps-hardware.md new file mode 100644 index 0000000000..78d8bade40 --- /dev/null +++ b/variants/linux/docs/gnss-pps-hardware.md @@ -0,0 +1,198 @@ +# PPS and NTP on the Waveshare LoRaWAN/GNSS HAT + +Hardware notes split out of [../README.md](../README.md), where the `## GPS` +section introduces the mod and links here. None of it is reachable from +meshcored: it is a soldering, device-tree and chrony guide for one specific +board, kept because getting any of it wrong is expensive and the details are +hard to find. + +Read `## GPS` in the README first — it covers `gps_device`, gpsd and the +NMEA-only chrony setup, which is the part that works without touching the +hardware. PPS is a precision layer on top of that, never a replacement. + +## Tapping the signal + +The `PPS` net's only accessible copper is R19 and the `L_PPS1` anode. R19 is an +0402 — use 30 AWG wire and glue it down, because the pad will lift the first time +that wire is flexed. + +**Option 1 — R19's Q1-side pad.** Full 3.3 V CMOS swing, LED keeps working. The +short trace is Q1 pin 4 → R19; the long one is R19 → LED. Identify the pad by +continuity to Q1 pin 4 (fourth castellated pad from the pin-1/GND corner), or with +a scope: the `PPS`-side pad swings 0→3.3 V, the LED-side pad clamps at the LED's +~1.9 V forward drop. **Do not tap the LED side** — 1.9 V is marginal against the +Pi's V_IH. Fit a 220–330R resistor in series. + +**Option 2 — remove `L_PPS1` and tap its anode pad.** Mechanically easier: bigger +pad, at the board edge next to P5's through-holes for strain relief, and R19's +510R becomes the series protection, so do not add another. With the LED gone that +node swings the full 0→3.3 V; the cost is losing the indicator, which leaves the +[checks below](#verifying-the-overlay) as the only way to see pulses arriving. + +**This board runs option 2**, and it works: `ppstest` shows ~±3 µs of inter-pulse +jitter and chrony settles at a single-digit-microsecond RMS offset. + +## Landing it on the Pi + +Land the other end on a free P1 pad on the HAT's own underside, so the mod stays +self-contained and connects through the header when the HAT is seated. + +**BCM 26 (P1 pin 37)** is the recommendation: unused by either pin set in +`meshcored.ini`, with GND adjacent at pin 39 for the return. BCM 19 (35), BCM 12 +(32), BCM 25 (22) and BCM 27 (13) also work. Check the `lora_irq_pin` and +`lora_reset_pin` in your own config before committing. + +Pins to stay off: + +| Pin(s) | Reason | +| --- | --- | +| 1, 17 | Pi 3V3 supply. The HAT takes only 5 V and regulates its own 3V3 with AMS-1, so these pads dangle on the HAT — but the Pi still drives its rail onto them, and a tap here shorts `PPS` into it | +| 27, 28 | `ID_SD`/`ID_SC`, reserved for HAT EEPROM ID detection at boot | +| 3, 5 | BCM 2/3. Usable, but they carry the Pi's 1.8K I²C pull-ups | +| 7 | BCM 4 — GPS `STANDBY` | +| 8, 10 | BCM 14/15 — GPS UART | +| 19, 21, 23 | SPI to the SX1262 | +| 31 | BCM 6 — `DIO4`, one of the two `CTRL` inputs of the PE4259 RF switch | +| 12, 36, 38, 40 | `RST`, `DIO1`, `BUSY`, `CS` | + +Both conventional `pps-gpio` pins are already taken on this HAT: GPIO 18 is `RST` +and GPIO 4 is `STANDBY`. + +Since the HAT regulates its own 3V3, the PPS high level comes from the AMS1117 +rather than the Pi's rail. Both are nominally 3.3 V and grounds are common, so no +level shifting is needed — but the two rails are independently derived, which is +one more reason for the series resistor. + +## Loading the overlay + +`/boot/firmware/config.txt`: + +``` +dtoverlay=pps-gpio,gpiopin=26,pull=down +``` + +The pull-down matters: whenever the L76K is in standby, or simply not powered +yet, it stops driving its pin 4 and the Pi's input would float. `pull` is not in +every version of the overlay — `dtoverlay -h pps-gpio` lists what yours takes, and +an external 10K to GND does the same job. Leave `assert_falling_edge` at its +default; the L76K marks the top of the second with a **rising** edge, 100 ms wide. + +## Verifying the overlay + +**The kernel prints nothing on success.** There is no `pps-gpio` line in `dmesg`, +and `lsmod` shows `pps_gpio` at refcount 0 even when bound (that column counts +dependent modules, not device bindings). Neither absence means anything is wrong. + +The three checks that do carry information: + +```sh +cat /sys/class/pps/pps*/name # want one reading pps@1a (0x1a = GPIO 26) +gpioinfo | grep -w 26 # want: "GPIO26" "pps@1a" input [used] +ls -l /sys/bus/platform/drivers/pps-gpio/ # want a symlink named pps@1a +``` + +Two traps once those pass: + +- **gpsd creates a second, permanently silent PPS device.** It attaches a PPS line + discipline to the serial port, which appears as another `/dev/ppsN` named + `serial0`. Nothing drives DCD there, so its assert counter stays at zero and + `ppstest` on it hangs. Check the `name` files rather than assuming `/dev/pps0` + is the GPIO one. +- **The numbering is not guaranteed.** The GPIO source is `pps0` only because the + platform driver binds at boot, before gpsd starts. For a stable name, add + `/etc/udev/rules.d/10-pps-gpio.rules`: + + ``` + SUBSYSTEM=="pps", ATTR{name}=="pps@1a.-1", SYMLINK+="pps-gpio" + ``` + +Then watch actual pulses. `/dev/pps*` is mode 600 root:root, so this needs `sudo` +— chronyd is unaffected, as it opens refclocks before dropping privileges: + +```sh +sudo apt install pps-tools +sudo ppstest /dev/pps-gpio # want ~1.000000 s between assert events +``` + +Nothing appears until the receiver has a fix — the L76K gates PPS on that, so a +silent device under a cold start is expected, not a wiring fault. + +## Handing PPS to chrony + +`/etc/chrony/chrony.conf`, amending the single `SHM 0` line from above: + +``` +refclock SHM 0 refid GPS offset 0.307 delay 0.2 noselect +refclock PPS /dev/pps-gpio refid PPS lock GPS prefer +``` + +Use `/dev/pps-gpio`, not `/dev/pps0` — install the udev rule above first. Pointing +this line at gpsd's silent serial PPS device fails quietly; chrony simply never +gets a sample. + +`noselect` keeps NMEA labelling seconds without ever disciplining the clock. As a +side effect the NMEA line stops being reported as a falseticker (`#x`) and shows +as `#?`, which for a `noselect` source is the healthy state. + +**Adding PPS inverts how to tune the `offset`.** It is no longer a calibration — +NMEA is `noselect`, so its value has no effect on the clock. Its only remaining +job is to keep NMEA inside ±0.5 s, the nearest-second rounding limit, so +`lock GPS` pairs each pulse with the right second. + +For that job a **larger offset is safer than a smaller one**. The delay is +physically one-sided: NMEA can only arrive *after* the second it describes, never +before, so raising the offset costs nothing on the low side and buys headroom on +the high side where all the risk lives. On this node the raw delay was measured at +0.307 s, then 0.193 s an hour later, then 0.384 s twenty minutes after that, with +nobody touching gpsd; `offset 0.307` kept the reported error inside ±120 ms +throughout, where a "correction" to zero would have left 116 ms of margin. + +So once PPS is running, leave the offset alone and treat a non-zero reading on the +`GPS` line as normal. Comment the value in `chrony.conf`, or the next person to +read it will tune it toward zero. + +Give it a couple of minutes to accumulate samples. `chronyc sources` should end up +with `#* PPS` selected and the NTP servers demoted to `^-`, and `chronyc tracking` +should report `Stratum : 1` and `Reference ID : 50505300 (PPS)`. + +Measured on this HAT after the mod: raw inter-pulse jitter at `ppstest` around +±3 µs, which is Pi interrupt latency rather than the receiver; chrony's filtered +result settles around **±400 ns** dispersion and a sub-microsecond RMS offset. + +## Serving the time to the LAN + +chronyd does not offer the clock by default and gives no hint that it is holding +back: with no `allow` directive chrony 4 never opens UDP/123, so a client sees a +silent timeout rather than a refusal and `ss -lun` on the node shows only +chronyc's `127.0.0.1:323` control socket. + +Debian's `chrony.conf` starts with `confdir /etc/chrony/conf.d`, so add a file +there rather than editing the shipped config: + +``` +# /etc/chrony/conf.d/serve-lan.conf +allow 192.168.0.0/16 +``` + +```sh +sudo systemctl restart chrony +ss -lun | grep :123 # want: 0.0.0.0:123 +sudo chronyc serverstats # "NTP packets received" climbs as clients appear +sudo chronyc clients # who is asking +``` + +**Give the `allow` a subnet.** A bare `allow` permits everything, and the node +runs no firewall — if its interface has a globally-routable IPv6 address, an +unqualified `allow` publishes a stratum-1 server to the internet. The +address-family split matters too: `allow 192.168.0.0/16` covers v4 only, so v6 +clients keep timing out until a second `allow` names their prefix. Name the +prefix; do not reach for the bare form to fix it. + +The restart is not free. chronyd comes back with an empty refclock filter and +falls back to the pool — stratum 4 for a couple of minutes — before PPS +re-accumulates samples. Clients asking during that window get the pool's time, +correctly labelled. + +A single-source stratum 1 is also a single point of failure: lose sky and this +node quietly becomes a stratum 3–4 relay of the Debian pool. Clients should list +it alongside a couple of internet servers rather than pointing at it alone. diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index d7b17d790c..c9623388bf 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -27,3 +27,19 @@ lora_tcxo = 1.8 #current_limit = 140 #dio2_as_rf_switch = 1 #rx_boosted_gain = 1 + +# GPS. Leave gps_device unset/empty to disable GPS. +# gps_device = /dev/ttyACM0 # serial NMEA: /dev/ttyUSB0, /dev/serial/by-id/... +# gps_device = gpsd:// # or read from gpsd (default 127.0.0.1:2947), which +# # lets gpsd+chrony discipline the host clock +# gps_baud = 9600 # 4800/9600/19200/38400/57600/115200; serial only +# gps_en_pin = 4 # GPIO held HIGH to wake a GPS that boots in standby +# # (e.g. L76K STANDBY on the Waveshare LoRaWAN/GNSS HAT) + +# defer_clock overrides whether meshcored sets the system clock from GPS/mesh +# time sync. Unset (default): on exactly when gps_device is gpsd:// above. +# true: never set the clock, even without gpsd:// -- e.g. a serial gps_device +# whose NMEA is separately fed to chrony. false: always attempt to set it, +# even against a gpsd:// device -- bounded, because a step of the system clock +# also steps millis(). See "Disciplining the host clock from GNSS" in README.md. +# defer_clock = true diff --git a/variants/linux/meshcored.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 index 96b5040a9f..517bfbd053 100644 --- a/variants/linux/meshcored.ini.pow-sx1262 +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -21,3 +21,10 @@ admin_password = changeme lat = 0.0 lon = 0.0 + +# This HAT has no onboard GPS. If one is added (e.g. a USB receiver fed to a +# local gpsd), see gps_device/gps_baud/gps_en_pin in meshcored.ini.waveshare +# and "Disciplining the host clock from GNSS" in README.md. defer_clock +# overrides whether meshcored sets the system clock from GPS/mesh time sync; +# unset (default) follows gps_device's transport. +# defer_clock = true diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare index 45b03bf82f..977a68252a 100644 --- a/variants/linux/meshcored.ini.waveshare +++ b/variants/linux/meshcored.ini.waveshare @@ -23,3 +23,27 @@ admin_password = changeme lat = 0.0 lon = 0.0 + +# GNSS, on the LoRaWAN/GNSS variant of this HAT only (the plain LoRa HAT has no +# receiver -- leave all three commented there). The L76K's NMEA is on the Pi's +# own UART, so /dev/ttyS0 needs the serial console off and enable_uart=1 set. +# +# gps_en_pin is not optional on this board: GPIO 4 reaches the L76K STANDBY line +# through R13, and driving it low stops NMEA. Slide switch S1 is wired in +# parallel with it -- if the GPS never sleeps, that switch is why. +# +# gps_device = /dev/ttyS0 # meshcored owns the UART +# gps_device = gpsd:// # or gpsd owns it, which also lets chrony +# # discipline the host clock from GNSS. +# # See "Disciplining the host clock from GNSS" +# # in README.md. +# gps_baud = 9600 # serial only; gpsd owns the baud rate itself +# gps_en_pin = 4 + +# defer_clock overrides whether meshcored sets the system clock from GPS/mesh +# time sync. Unset (default): on exactly when gps_device is gpsd:// above. +# true: never set the clock, even without gpsd:// -- e.g. a serial gps_device +# whose NMEA is separately fed to chrony. false: always attempt to set it, +# even against a gpsd:// device -- bounded, because a step of the system clock +# also steps millis(). See "Disciplining the host clock from GNSS" in README.md. +# defer_clock = true diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini index fb701b5222..ed6bfc190f 100644 --- a/variants/linux/platformio.ini +++ b/variants/linux/platformio.ini @@ -8,9 +8,11 @@ build_flags = -D MAX_NEIGHBOURS=100 -D LORA_TX_POWER=22 -D MESH_DEBUG=1 + -D ENV_INCLUDE_GPS=1 build_src_filter = ${linux_base.build_src_filter} +<../examples/simple_repeater> lib_deps = ${linux_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 diff --git a/variants/linux/target.cpp b/variants/linux/target.cpp index 98774eaac2..f5f1baa48c 100644 --- a/variants/linux/target.cpp +++ b/variants/linux/target.cpp @@ -32,7 +32,13 @@ RADIO_CLASS radio = radio_module; WRAPPER_CLASS radio_driver(radio, board); LinuxRTCClock rtc_clock; -EnvironmentSensorManager sensors; +LinuxGpsStream gps_serial; +MicroNMEALocationProvider gps_location(gps_serial, &rtc_clock, -1, -1, NULL); +EnvironmentSensorManager sensors(gps_location); + +bool linux_gps_present() { + return gps_serial.isPresent(); +} #ifdef DISPLAY_CLASS DISPLAY_CLASS display; @@ -42,6 +48,20 @@ EnvironmentSensorManager sensors; bool radio_init() { rtc_clock.begin(); + // begin() dispatches on the device string, so the empty-means-disabled test + // lives inside it rather than here. + gps_serial.begin(board.config.gps_device, board.config.gps_baud); + + // Has to follow begin(), which is what establishes the transport. The + // transport-derived default only catches the gpsd:// case; `defer_clock` in + // meshcored.ini overrides it explicitly for setups the transport string + // cannot see for itself (e.g. a serial gps_device the operator is feeding + // to chrony some other way, alongside CAP_SYS_TIME). + bool defer = board.config.defer_clock >= 0 + ? (board.config.defer_clock != 0) + : (gps_serial.transport() == LinuxGpsStream::GPSD_SOCKET); + rtc_clock.setExternallyDisciplined(defer); + // Rebuild the radio on a Module carrying the configured pins. Assigning over // the object rather than replacing it is deliberate and required: radio_driver // holds a reference to `radio`, so the address has to stay put. Only the diff --git a/variants/linux/target.h b/variants/linux/target.h index 1f5539ca94..3b16f4ee05 100644 --- a/variants/linux/target.h +++ b/variants/linux/target.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include "LinuxGpsStream.h" #ifdef DISPLAY_CLASS #include #include @@ -19,6 +21,13 @@ extern LinuxBoard board; extern WRAPPER_CLASS radio_driver; extern LinuxRTCClock rtc_clock; extern EnvironmentSensorManager sensors; +extern LinuxGpsStream gps_serial; +extern MicroNMEALocationProvider gps_location; + +// True if there is a GPS to talk to: an opened serial device, or a configured +// gpsd source (which may connect later). Consumed by +// EnvironmentSensorManager::initBasicGPS() on the Linux build. +bool linux_gps_present(); #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display;