NMEA 2000
On a lot of machines the position fix does not arrive over a serial cable into
your application — it arrives on the CAN bus, broadcast by a receiver that
speaks NMEA 2000. This tutorial is about consuming that traffic: how GNSS
position, course, speed, heading, fix quality, and date/time show up as messages
on the wire, how machbus decodes them into engineering units, and how you
read the result through the session facade’s GNSS handle without writing a byte
parser yourself.
NMEA 2000 rides the same physical CAN network as ISOBUS and J1939 and uses the same parameter-group (PGN) addressing. That means a GNSS receiver, an ISOBUS task controller, and a tractor ECU can share one bus, and your node can listen to all of them at once.
Why this exists
A guidance system, a section controller, or a yield logger all need to know where the machine is and which way and how fast it is moving. On a vehicle network it is cheaper and more robust to publish the fix once and let every interested node read it than to wire a serial cable to each consumer. NMEA 2000 is the convention for doing that on agricultural and marine CAN buses: the receiver broadcasts a small set of well-known PGNs at a steady rate, and consumers subscribe.
The catch is that the data on the wire is packed: latitudes are scaled integers,
angles are radians times a fixed factor, and “no value” is a reserved sentinel
rather than a blank. You must decode correctly and never treat a sentinel as a
real reading. That decode-and-validate step is what machbus does for you.
Mental model
GNSS receiver on the bus
│ broadcasts PGNs at a steady rate
▼
┌──────────────────────────────────────────┐
│ position rapid COG/SOG heading │
│ position detail attitude system time │ ← single-frame + fast-packet
│ DOPs rate-of-turn variation │
└──────────────────────────────────────────┘
│ decode scaled ints, drop sentinels
▼
NMEAInterface ── cache the latest fix ──► GNSSPosition
│ re-emit as events
▼
Gnss plugin ──► latest_position() / GnssEvent
The decoder keeps one cached fix (latest_position) and updates it field by
field as each PGN arrives. Position comes from one PGN, course and speed from
another, heading and attitude from yet others — they merge into the same cached
GNSSPosition. Your application reads the cache or subscribes to events; it
never sees raw bytes.
Anatomy: the message groups
machbus decodes the common navigation PGNs and a wider set of environmental,
engine, and power groups. The navigation ones are what a guidance or logging
node cares about. Each maps to a typed struct or a cached field.
Position
Two PGNs carry position, at two precisions:
- Position rapid update (PGN 129025) is a single CAN frame: just latitude
and longitude as scaled 32-bit integers, sent often.
machbusdecodes it intoGNSSPosition.wgs(aconcord::Wgswithlatitude,longitude,altitude). Because it is so small it can be broadcast several times a second, which is why it is the workhorse for steering. - GNSS position data (PGN 129029) is the detailed report: latitude,
longitude, altitude, the fix method, satellites used, and DOP values, plus
optional reference-station entries. It is much larger than 8 bytes, so it is a
Fast Packet message (see below).
machbusfills inaltitude_m,fix_type,satellites_used,hdop, andpdopfrom it.
Both feed the same cached GNSSPosition. When the detailed report lands it
carries altitude and fix detail the rapid one cannot; when the rapid report
lands between detailed ones it keeps the cached position moving smoothly.
There is also a position delta rapid update (PGN 129027) that carries a tiny
time-and-position increment rather than an absolute fix. machbus applies the
delta on top of the cached position, which lets a receiver publish high-rate
movement cheaply between full fixes.
Fix quality, mode, and satellites
A position is only as trustworthy as its fix. machbus exposes the fix method
through GNSSFixType, with variants spanning no-fix, plain GNSS, differential,
precise, RTK fixed, RTK float, and dead-reckoning, plus error and
unavailable. GNSSPosition gives you two helpers built on it:
has_fix()— true unless the fix type isNoFix.is_rtk()— true forRTKFixedorRTKFloat, the centimetre-class modes guidance usually requires.
Dilution-of-precision values (hdop, pdop, vdop) and satellites_used
round out the quality picture and arrive in the detailed position PGN and in the
standalone GNSS DOPs report (PGN 129539), decoded into GNSSDOPData with
desired and actual GNSSDOPMode plus horizontal, vertical, and time DOP.
Course, heading, and speed over ground
Direction and speed come from several PGNs that machbus folds into the cached
fix:
| What | PGN group | Where it lands |
|---|---|---|
| Course over ground + speed over ground | COG/SOG rapid (129026) | cog_rad, speed_mps |
| Heading / track control | heading-track (127250) | heading_rad |
| Attitude (yaw, pitch, roll) | attitude (127257) | heading_rad, pitch_rad, roll_rad |
| Rate of turn | rate-of-turn (127251) | rate_of_turn_rps |
| Magnetic variation | magnetic variation (127258) | event only |
Course over ground is the direction of travel; heading is where the machine is
pointed. They differ under side-slip, so machbus keeps them as separate
fields. All angles are radians.
Date and time
System time (PGN 126992) decodes into SystemTimeData: a TimeSource
(GPS, GLONASS, a local clock, …), days since the epoch, and seconds since
midnight. It is the bus-published clock you stamp logs against when you do not
trust the local one.
Network management and product info
Separate from navigation, NMEA 2000 has a small network-management layer that
machbus models in n2k_management. It is not about position at all — it is
how nodes describe themselves and prove they are alive:
- Product information (
N2KProductInfo, PGN 126996) — the NMEA 2000 protocol version, product and model identifiers, software version, serial code, certification level, and load equivalency. A fixed-layout Fast Packet payload. - Configuration information (
N2KConfigInfo, PGN 126998) — two installation description strings plus a manufacturer string. - Heartbeat (
N2KHeartbeat, PGN 126993) — a tiny periodic “I am still here” frame carrying an update interval and a rolling sequence counter.
N2KManagement builds these outbound, answers requests, tracks pending requests
with a timeout, and emits events (on_product_info_received,
on_heartbeat_received, and so on) when peers report theirs. Think of it as the
courtesy layer that lets a tool enumerate who is on the bus.
Decoding scaled integers and sentinels
Every numeric field on the wire is a scaled integer. To get an engineering value
you multiply the raw integer by a fixed resolution. machbus keeps those
factors as named constants, for example:
- latitude / longitude:
LAT_LON_RESOLUTION(1e-7 degrees per count) - altitude:
ALTITUDE_RESOLUTION - speeds:
SPEED_RESOLUTION - headings and courses:
HEADING_RESOLUTION,COG_RESOLUTION - DOP values:
DOP_RESOLUTION
The crucial rule: a field can declare itself not available with a reserved
all-ones value (0xFFFF for a 16-bit field, 0x7FFFFFFF for a signed 32-bit
one, and so on). machbus checks for these sentinels before scaling and skips
the field rather than producing a bogus number. So if a receiver has no altitude
solution, altitude_m stays None — it does not become a wildly wrong figure.
This is why so many fields on GNSSPosition are Option<f64>: present means
real, None means the receiver said “not available”. Inbound frames from the
null or broadcast source address are also rejected outright before any decode.
Each PGN has its own transmit priority
Appendix B.1 gives every parameter group a “Priority Default”, and they are not the same. Across the groups machbus sends they span four levels:
| Priority | Groups |
|---|---|
| 2 | 129025 position rapid, 129026 COG/SOG, 129027 position delta, 127250 heading, 127251 rate of turn, engine/transmission rapid |
| 3 | 129029 GNSS position data, 126992 system time, 127257 attitude, switch state |
| 5 | 127497 engine trip |
| 6 | 129539 DOPs, 129540 satellites in view, 127258 magnetic variation |
| 7 | 126993 heartbeat |
Lower wins arbitration, so this is not cosmetic: the 10 Hz position stream an
autonomy consumer gates on is meant to outrank the once-a-second housekeeping
groups. Sending everything at one priority — the J1939 general default of 6 is
the easy mistake — puts a position fix behind its own dilution-of-precision
report on a loaded bus. nmea2000_default_priority() holds the table, and the
GNSS plugin applies it per frame.
A reserved Sequence ID costs the binding, not the data
DD056 defines the SID as a correlation tag: “identical SID values within two or more different PGN transmissions identifies those PGN transmissions as a single related data set”. Values 0–252 bind, 253–254 are reserved, 255 means “No binding provided”.
Because the SID carries no measurement, machbus does not discard a parameter group over a reserved one — it maps it to 255 and decodes the rest. You still cannot correlate that frame with a matching COG/SOG, which is the honest outcome; you do not lose the position to punish its label.
Why Fast Packet for the big ones
A classic CAN frame carries at most 8 bytes. The position-rapid and COG/SOG
PGNs fit in one frame, which is exactly why they exist as separate “rapid” PGNs.
But the detailed GNSS position report and the product-info payload are far
larger than 8 bytes. NMEA 2000 reassembles those from a sequence of frames using
Fast Packet: the first frame announces the total length and the rest carry
ordered fragments. machbus validates the reassembled payload length before
decoding the detailed position PGN, and rejects a partial or malformed
reassembly rather than reading past the end. For the full mechanics of the
fragmentation and reassembly, see Fast Packet.
Position freshness and fix-quality gating
A cached fix has two failure modes that the decoder cannot decide for you: staleness and low quality. Both are your application’s call.
- Staleness. Every
GNSSPositioncarriestimestamp_us, set from the message that produced it. If the receiver drops off the bus, the cache keeps the last good fix — it does not blank out. You must compare the timestamp against “now” and refuse to act on a fix older than your control loop tolerates. A guidance node steering off a five-second-old position is worse than one that stops. - Quality. Before you feed a fix to steering, gate on it: require
has_fix(), often requireis_rtk(), and optionally boundhdoporsatellites_used. ANoFixor a float fix with high DOP is not good enough for centimetre guidance even if it is fresh.
The decoder gives you the raw material — fix type, DOPs, satellite count, timestamp — and never pretends a weak fix is strong. Turning that into a go/no-go decision is your responsibility.
Doing it with machbus
There are two entry points: the standalone NMEAInterface codec, and the
Gnss plugin that wraps it for applications.
The NMEAInterface codec
NMEAInterface is a pump-style codec with a built-in cache. You construct it
with an NMEAConfig of listen toggles, subscribe to the native events you care
about (on_position, on_cog, on_sog, on_attitude, on_system_time,
on_gnss_dops, …), and feed inbound Messages to handle_message. It updates
the cached latest_position() and fires the matching events. The GNSS monitor
example wires up the listeners and drives a position-rapid, a COG/SOG, and an
attitude message through it:
#![allow(unused)]
fn main() {
{{#include ../../../examples/gnss_monitor.rs:31:49}}
}
After those three messages the cache holds a single merged fix — position from the first, course and speed from the second, attitude angles from the third — which the example then prints.
NMEAConfig defaults to the navigation profile and can be narrowed or widened.
with_gnss_navigation(true) enables exactly the position, COG/SOG, heading,
attitude, rate-of-turn, DOPs, magnetic-variation, and system-time groups without
turning on the environmental, engine, battery, rudder, or depth decoders.
with_all(true) turns on everything. Leaving a group off means its frames are
ignored.
The Gnss plugin
For an application that has already claimed an address, the Gnss plugin owns
the codec. Reach it through ctrl.with_mut::<Gnss, _>(|g| ...); its surface is
small and task-focused:
latest_position()— the cachedGNSSPosition, orNoneif no fix yet.broadcast_position,broadcast_cog_sog— broadcast your own fix on the bus when your node is the source.interface_mut()— the underlyingNMEAInterfacefor configuration the plugin does not expose.
GNSS updates (GnssEvent::Position, Cog, Sog, Heading,
MagneticVariation, Attitude, Dops, SystemTime) arrive on the event
stream via driver.poll() or ctrl.drain::<GnssEvent>(). The plugin registers
inbound PGN callbacks that stage raw messages; each driver.poll()? drains them
through the codec, so the cached position stays current as you pump the bus.
Events and responsibilities
| Event | Meaning | Typical action |
|---|---|---|
GnssEvent::Position | The cached fix changed. | Re-evaluate freshness and quality; act if good. |
GnssEvent::Cog / Sog | Course / speed over ground updated. | Update motion model. |
GnssEvent::Heading | Heading updated. | Feed steering / display. |
GnssEvent::Attitude | Yaw/pitch/roll updated. | Terrain compensation, tilt. |
GnssEvent::Dops | DOP report arrived. | Update the quality gate. |
GnssEvent::SystemTime | Bus clock update. | Stamp logs if trusting the bus clock. |
on_*_received (n2k_management) | A peer reported product/config/heartbeat. | Update the node inventory. |
Your responsibilities: poll the driver so messages drain, gate on freshness and fix quality before acting, and — if your node is a source — only broadcast position once you own an address.
Edge cases and failures
- No fix yet.
latest_position()returnsNoneuntil the first decodable position arrives. Handle theNonecase explicitly; do not unwrap blindly. - Stale fix. The cache never expires on its own. A receiver that goes silent
leaves the last fix in place with its old
timestamp_us. You must time it out. - Sentinel “not available”. A field set to its reserved all-ones value is
skipped, so optional fields stay
Noneand required ones leave the cache unchanged. TreatNoneas “unknown”, never as zero. - Partial or malformed Fast Packet. The detailed position decoder checks the reassembled length (including any reference-station entries) and rejects a payload that does not add up, rather than reading garbage.
- Wrong source address. Frames claiming the null or broadcast address as their source are dropped before decode — they cannot poison the cache or fire events.
- Disabled group. If the relevant
NMEAConfigtoggle is off, those frames are silently ignored. A “missing” event is often just a config gap.
Advanced
- Feeding guidance and TC-GEO. A gated, fresh
GNSSPositionis the input to guidance and to geo-referenced prescription. Convert the WGS84 fix to a local frame withto_enu,to_ned, orto_ecf(using a chosen reference origin) before doing planar geometry. See TC-GEO prescription. - Batching. When you process a trajectory rather than a single fix, collect
positions into a
GNSSBatchand convert them all at once withto_enu_batch,to_ned_batch, orto_ecf_batch. The batch demo builds a short trajectory around a reference origin:
#![allow(unused)]
fn main() {
{{#include ../../../examples/gnss_batch.rs:17:28}}
}
- Rate handling. Position-rapid and COG/SOG arrive faster than the detailed report. Decide which cadence your control loop runs at and read the cache at that rate rather than reacting to every frame; the cache always holds the most recent merged state.
- Source vs. consumer. Most nodes only consume. If your node produces a
fix, the
send_*methods on theGnssplugin (and thebuild_*builders onNMEAInterface) encode the same PGNs back onto the bus. - Network management. Use
N2KManagementto broadcast heartbeats on a cadence, answer product- and config-info requests, and discover peers. It is independent of the navigation decode path.
Validate locally
make run EXAMPLE=gnss_monitor
make run EXAMPLE=gnss_batch
make test
The GNSS monitor drives position, COG/SOG, and attitude messages through the
codec in software and prints the merged cached fix. The batch demo converts a
WGS84 trajectory to local ENU/NED/ECEF frames. make test runs the round-trip
and sentinel-rejection tests for the codec and the network-management layer.
What this proves / does not prove
Proves: machbus decodes the common NMEA 2000 GNSS and navigation PGNs into
typed engineering values, merges them into one cached fix, rejects sentinels and
bad source addresses, and surfaces the result through the Gnss plugin —
all verifiable in software.
Does not prove: behaviour against a specific third-party receiver, real-bus
timing and rates, or any NMEA 2000 certification or approval. A real deployment
still needs official standards, real hardware, and interoperability evidence.
machbus ships no NMEA certification and implies none.
See also
- NMEA and GNSS basics — the conceptual primer on GNSS-on-CAN.
- Serial GNSS — the same fix arriving over a serial link instead of the bus.
- Fast Packet — how the larger PGNs are fragmented and reassembled.
- TC-GEO prescription — feeding a gated position into geo-referenced application maps.