Conversation
mmmorks
force-pushed
the
pr/02-config-validation
branch
from
September 8, 2026 04:11
b61c48c to
037b93f
Compare
…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.
mmmorks
force-pushed
the
pr/02-config-validation
branch
from
September 12, 2026 21:21
037b93f to
8ecdd24
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.