Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. machbus decodes it into GNSSPosition.wgs (a concord::Wgs with latitude, 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). machbus fills in altitude_m, fix_type, satellites_used, hdop, and pdop from 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 is NoFix.
  • is_rtk() — true for RTKFixed or RTKFloat, 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:

WhatPGN groupWhere it lands
Course over ground + speed over groundCOG/SOG rapid (129026)cog_rad, speed_mps
Heading / track controlheading-track (127250)heading_rad
Attitude (yaw, pitch, roll)attitude (127257)heading_rad, pitch_rad, roll_rad
Rate of turnrate-of-turn (127251)rate_of_turn_rps
Magnetic variationmagnetic 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:

PriorityGroups
2129025 position rapid, 129026 COG/SOG, 129027 position delta, 127250 heading, 127251 rate of turn, engine/transmission rapid
3129029 GNSS position data, 126992 system time, 127257 attitude, switch state
5127497 engine trip
6129539 DOPs, 129540 satellites in view, 127258 magnetic variation
7126993 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 GNSSPosition carries timestamp_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 require is_rtk(), and optionally bound hdop or satellites_used. A NoFix or 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 cached GNSSPosition, or None if no fix yet.
  • broadcast_position, broadcast_cog_sog — broadcast your own fix on the bus when your node is the source.
  • interface_mut() — the underlying NMEAInterface for 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

EventMeaningTypical action
GnssEvent::PositionThe cached fix changed.Re-evaluate freshness and quality; act if good.
GnssEvent::Cog / SogCourse / speed over ground updated.Update motion model.
GnssEvent::HeadingHeading updated.Feed steering / display.
GnssEvent::AttitudeYaw/pitch/roll updated.Terrain compensation, tilt.
GnssEvent::DopsDOP report arrived.Update the quality gate.
GnssEvent::SystemTimeBus 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() returns None until the first decodable position arrives. Handle the None case 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 None and required ones leave the cache unchanged. Treat None as “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 NMEAConfig toggle 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 GNSSPosition is the input to guidance and to geo-referenced prescription. Convert the WGS84 fix to a local frame with to_enu, to_ned, or to_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 GNSSBatch and convert them all at once with to_enu_batch, to_ned_batch, or to_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 the Gnss plugin (and the build_* builders on NMEAInterface) encode the same PGNs back onto the bus.
  • Network management. Use N2KManagement to 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