variants/linux: GPS from a serial device or gpsd, and host-clock discipline - #6
Open
mmmorks wants to merge 1 commit into
Open
variants/linux: GPS from a serial device or gpsd, and host-clock discipline#6mmmorks wants to merge 1 commit into
mmmorks wants to merge 1 commit into
Conversation
…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
mmmorks
force-pushed
the
pr/02-config-validation
branch
from
September 8, 2026 04:11
b61c48c to
037b93f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds GPS support to the Linux build.
ENV_INCLUDE_GPSwas off because ardulinux has no hardware UART (initBasicGPS()probesSerial1). This adds a Linux GPS byte source,LinuxGpsStream, and wires the sharedMicroNMEALocationProviderover it, so the standardgpsCLI commands and location telemetry work on a Pi exactly as on an MCU. The source is selected bygps_deviceinmeshcored.ini:/dev/ttyS0,/dev/ttyACM0, …) opened directly, baud fromgps_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 ArduinoStream. 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 agps off/gps ongap: 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 baseLinuxGpsStreamderives from. (Identical to the file the control-socket console PR adds; git merges the duplicate cleanly.)meshcored.inikeys: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.initBasicGPS()does on MCUs races receivers that take many seconds to emit their first sentence and, when it lost, permanently disabled thegpsCLI 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/falseoverrides it. Side effect worth having: aclock 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 lacksCAP_SYS_TIME.gps_baudbeside agpsd://device is reported as inert;gps_en_pinbeside agpsd://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.## GPSsection covering the CLI commands, serial GPS, the gpsd + chrony setup (including the refclockoffsetchrony 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 newtest_linux_gps_streamcases).linux_repeaterbuilds for arm64 in the Docker container.gps_device = /dev/ttyS0withgps_en_pin = 4gives fix/19 sats through thegpsCLI;gps_device = gpsd://with chrony'srefclock SHM 0 ... offset 0.307gives aGPSsource 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 itsparse_*helpers andLoadResult). Independent of the SX1262 / event-loop / console chain. Both this branch and the event-loop branch add-I variants/linuxand a source entry to thenativetest env inplatformio.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 ininitBasicGPS()that asks the variant whether a GPS device is open instead of probingSerial1; MCU path untouched. Plus a comment recording whygps_intervalis settable but not enumerated (enumerating it would change the companionCUSTOM_VARSwire payload).examples/simple_repeater/MyMesh.h—applyGpsPrefs()re-applies the persistedgps_intervalon boot (companion_radio already does this).src/helpers/sensors/LocationProvider.h—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. No behaviour change; every subclass overrides it.src/helpers/CommonCLI.cpp— three generic fixes:gpsfed the NULL thatgetSettingByKey("gps")returns when no GPS setting is registered straight intostrcmp(). 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_intervalhas 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>replyERR: clock set was refusedwhen the clock did not move, instead ofOK.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 realArduino.hpulls in and MicroNMEA relies on) andtest/mocks/Mesh.h(a no-opMESH_DEBUG_PRINTLNfor the native build).platformio.ini—nativetest env:-I variants/linux, MicroNMEA as a test dependency, andLinuxGpsStream.cppin the test source filter.