Skip to content

variants/linux: GPS from a serial device or gpsd, and host-clock discipline - #6

Open
mmmorks wants to merge 1 commit into
pr/02-config-validationfrom
pr/06-gps
Open

mmmorks wants to merge 1 commit into
pr/02-config-validationfrom
pr/06-gps

Conversation

@mmmorks

@mmmorks mmmorks commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Staged on the fork for review. Final destination: l5yth/meshcore-linux linux. Base here is pr/02-config-validation so the diff shows only this change.

Summary

Adds GPS support to the Linux build. ENV_INCLUDE_GPS was off because ardulinux has no hardware UART (initBasicGPS() probes Serial1). This adds a Linux GPS byte source, LinuxGpsStream, 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. The source is selected by gps_device in meshcored.ini:

  • a serial device (/dev/ttyS0, /dev/ttyACM0, …) opened directly, baud from gps_baud;
  • 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 because the node's clock is the whole host's clock.

What changed

  • variants/linux/LinuxGpsStream.{h,cpp} (new): the two transports behind one Arduino Stream. The gpsd connect is a non-blocking state machine with backoff, so a slow, absent or restarted gpsd never stalls radio RX. gpsd's JSON control lines fail MicroNMEA's $-and-checksum test and are discarded (the test proves this 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.
  • variants/linux/PeekableStream.h (new): one-byte lookahead over a non-blocking descriptor, the base LinuxGpsStream derives from. (Identical to the file the control-socket console PR adds; git merges the duplicate cleanly.)
  • meshcored.ini keys: gps_device, gps_baud (validated against the bauds termios can program), gps_en_pin (a GPIO held 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), defer_clock. All three templates document them.
  • A configured device that opens is treated as detected. The byte-sniff initBasicGPS() does on MCUs races receivers that take many seconds to emit their first sentence and, when it lost, permanently disabled the gps CLI surface until restart.
  • LinuxRTCClock::setExternallyDisciplined(): with gpsd owning the receiver, chrony owns the clock, so the daemon declines to set it. Default follows the transport (gpsd:// → defer); defer_clock = true/false overrides it. Side effect worth having: a clock sync / time <epoch> carrying a timestamp from a remote mesh peer cannot retime a general-purpose host by configuration, rather than only because the shipped unit lacks CAP_SYS_TIME.
  • Startup diagnostics: gps_baud beside a gpsd:// device is reported as inert; gps_en_pin beside a gpsd:// device warns that the pin is unowned once meshcored exits and points at the GPIO-hog alternative.
  • test/test_linux_gps_stream (new, 30 cases): device parsing, serial open/baud/read over a pty, the stale-backlog flush and its negative case, gpsd handshake, reconnect after server drop and after a read gap, and that an unreachable gpsd keeps retrying without spinning.
  • README: a ## GPS section covering the CLI commands, serial GPS, the gpsd + chrony setup (including the refclock offset chrony needs before it will believe an NMEA-only source), the PPS tap on the Waveshare HAT and handing it to chrony, serving the time to the LAN, and keeping the GNSS stack up when meshcored is not running.

Why

A Linux node is usually a Pi with a GNSS HAT or a USB receiver; the firmware could not use either. Once it can, the natural next question on Linux is the host clock, and meshcored holding the UART exclusively is exactly what stops gpsd and chrony from answering it — hence the gpsd transport and the clock-discipline switch as part of the same change.

How it was tested

  • pio test -e native: 74/74 (including the 30 new test_linux_gps_stream cases).
  • linux_repeater builds for arm64 in the Docker container.
  • On hardware (Pi + Waveshare SX1262 LoRaWAN/GNSS HAT, L76K): serial gps_device = /dev/ttyS0 with gps_en_pin = 4 gives fix/19 sats through the gps CLI; gps_device = gpsd:// with chrony's refclock SHM 0 ... offset 0.307 gives a GPS source chrony selects when NTP peers are taken offline; with the PPS mod described in the README, chrony settles at sub-microsecond RMS offset.

Dependencies

Stacked on pr/02-config-validation (uses its parse_* helpers and LoadResult). Independent of the SX1262 / event-loop / console chain. Both this branch and the event-loop branch add -I variants/linux and a source entry to the native test env in platformio.ini; whichever lands second needs a trivial rebase there.

Shared code touched

All of these are needed for the Linux path; the first two are Linux-guarded, the rest are generic and small.

  • src/helpers/sensors/EnvironmentSensorManager.cpp#if defined(ARDULINUX_PLATFORM) branch in initBasicGPS() that asks the variant whether a GPS device is open instead of probing Serial1; MCU path untouched. Plus a comment recording why gps_interval is settable but not enumerated (enumerating it would change the companion CUSTOM_VARS wire payload).
  • examples/simple_repeater/MyMesh.happlyGpsPrefs() re-applies the persisted gps_interval on boot (companion_radio already does this).
  • src/helpers/sensors/LocationProvider.hsendSentence() gets an inline empty body. It was declared virtual with no definition anywhere, which leaves the vtable un-emitted under the Itanium ABI and fails to link on a native toolchain. No behaviour change; every subclass overrides it.
  • src/helpers/CommonCLI.cpp — three generic fixes:
    • crash fix: bare gps fed the NULL that getSettingByKey("gps") returns when no GPS setting is registered straight into strcmp(). glibc segfaults on it, so the command reliably killed meshcored on a node without GPS; MCUs happen to survive the same read.
    • gps interval [seconds]: NodePrefs::gps_interval has been persisted all along but nothing could set or apply it, leaving the 1 s default and two lat/lon debug lines per second in the journal. Bare form reports, argument form sets and persists (capped at 24 h; non-numeric input is rejected rather than silently read as 0).
    • clock sync / time <epoch> reply ERR: clock set was refused when the clock did not move, instead of OK. setCurrentTime() returns void, so this re-reads the clock with 2 s of slack. On targets where the set always takes, the reply is unchanged.
  • test/mocks/Arduino.h (<cctype>, which the real Arduino.h pulls in and MicroNMEA relies on) and test/mocks/Mesh.h (a no-op MESH_DEBUG_PRINTLN for the native build).
  • platformio.ininative test env: -I variants/linux, MicroNMEA as a test dependency, and LinuxGpsStream.cpp in the test source filter.

…ipline

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 <epoch>` 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant