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

variants/linux: GPS from a serial device or gpsd, and host-clock discipline#6
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.
  * `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.

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 leaves the vtable
    un-emitted under the Itanium ABI and fails to link on a native
    toolchain; every subclass overrides it.
  * CommonCLI: bare `gps` dereferenced the NULL that getSettingByKey("gps")
    returns when no GPS setting is registered -- glibc segfaults on it, so
    the command reliably killed meshcored on a node without GPS (MCUs
    happen to survive the same read). New `gps interval [seconds]`
    command: 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; simple_repeater's
    applyGpsPrefs() now re-applies it on boot. `clock sync` / `time` reply
    "ERR: clock set was refused" when the clock did not move, instead of
    "OK" -- on Linux the set can be refused (chrony owns it, or no
    CAP_SYS_TIME); on every other target the set 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. The README
covers 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, serving the time to the LAN, and keeping the GNSS stack
up when meshcored is not running.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4
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