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;