Trimble BD992 notes

Why the GNSS stack is shaped the way it is, what the hardware turned out to do, and what is still open. The pages this backs are bd992_bridge, gsof and bd992.

The two things the ICD does not document

Both are config fields rather than code, and --probe is how you settle them.

The first is which TCP port serves the command interface, and whether one IP socket can carry both GSOF output and inbound commands. Still open. A --probe against a live receiver on 2026-08-23 timed out on every application file index while that same socket streamed reports throughout. But the socket had been configured output-only, so that is what a correctly working receiver should do; it says nothing about whether a socket configured for input and output would answer. Set one up that way and probe it again; that is the experiment that settles it.

The run was not wasted, because of what the timeout does prove. ControlClient skips GENOUT packets while waiting for a reply, so it read every report that arrived during the three-second window and correctly declined to mistake any of them for an answer. A control client without that filter would have returned the first GSOF report as if it were an application file.

The second is which application file index holds the running configuration. Trimble documents index 0 as the factory defaults and says nothing about the rest. --probe walks 0 to 4 and prints what it finds. Still open, because it cannot be answered until there is a socket configured to accept commands.

Why the parsers are constexpr

A wrong GNSS field offset produces a plausible latitude, not a crash. It cannot be caught by looking at the output, and it is not reliably caught by a runtime assertion someone stops running. So every parser in libs/gsof is constexpr, and libs/gsof/tests/test_records.cpp asserts against real captures at compile time. If it builds, the offsets are right.

That constraint shapes the library. Values are assembled byte by byte rather than by memcpy into a packed struct, because memcpy is not usable in constant evaluation. gsof::Error is an enum plus two integers rather than something carrying a std::string, because a member that allocates makes an error value non-literal and static_assert(parse(bad).error().kind == ...) stops compiling. The framer is the one piece that is not constexpr, because it owns a buffer across calls.

The captures come from real receivers rather than being hand-written, because a vector authored from the same reading of the ICD as the parser agrees with the parser by construction, including where both are wrong. Captured records cross-check each other: records 35 and 41 report the same base station bit for bit, and record 7’s tangent-plane baseline is the distance between record 2’s rover and that base.

The capture that is not in the repository

Records 13, 14, 28, 48, 62, 70, 74, 91, 92 and 96 were validated against a live receiver. That is where their layouts came from and where the cross-checks that confirmed them were run, but the capture was taken privately and is not checked in. A GSOF capture is a position fix, and not only through the position records: the satellite azimuths and elevations in records 33, 34 and 48, taken against the timestamp in record 1, pin the observer down on their own, so scrubbing the position records does not make one safe to publish.

What stands in for it is a set of synthetic vectors, labelled as such, covering the parsers’ arithmetic. They catch a logic error. They cannot catch a field offset that is wrong the same way in both parser and vector, which is what a real capture is for. libs/gsof/tests/golden/README.md has the procedure for taking one somewhere publishable: a whole transmission off our own receiver can be checked against itself, since records 2, 3, 62 and 70 describe one position in four coordinate systems, record 28’s RTK age is record 38’s correction age, and records 1, 16, 62 and 91 stamp the same instant through two opposite week/time-of-week orderings.

Live hardware, 2026-08-23

A live BD992 with every message type enabled sends 30 record types. The decode path was validated against one: 8 820 records over five minutes with zero unknown, zero malformed, zero resyncs and zero discarded pages. Eleven of those record types were unmodelled before that session, and nothing but a receiver with everything switched on would have shown it. Every record parser has now been run against a live BD992; the command encodings have not, because the only socket tried was output-only.

Three smaller findings from the same session:

position_type.positionFixType had been trimmed to “what a BD992 in a vehicle can produce”. The receiver reported rtxFastLowLatency (33), which was not in the trimmed list, within the hour. The enum is now the whole ICD list and positionFixTypeRaw always carries the wire byte.

Record 34 truncates silently at 25 satellites once five constellations are in view; on our receiver it reported 24 of 31. Record 48 is the form that is not capped, and on the bench 31 entries covered 28 satellites, because an entry is a signal group rather than a satellite.

Record 74, second-antenna sigma, is still unsettled. The ICD gives it 38 body bytes ending in a two-byte epoch count; our receiver sends 42, with a float where that count should be. Which four bytes moved cannot be determined from a receiver whose second antenna is disconnected, because every field but a saturated range RMS reads zero. The parser reports the epoch count as absent rather than guessing, and hasEpochCount says so. Connect a second antenna and it settles in one epoch.

One topic per record, and who decides what belongs together

The mapping is strictly one topic per record type. A consumer wanting position and fix quality subscribes to two topics. That is the trade taken deliberately: a new record type is then a schema and a table row and nothing else, and nothing in the node decides which fields belong together. That is a vehicle state estimator’s job, and it wants the records rather than the bridge’s guess at which of them matter.

The receiver is normally configured with position fast and status slow (50 Hz position against 1 Hz accuracy is a reasonable setup), and which messages are enabled changes whenever someone changes their mind about what they need. Nothing in the node depends on either: records are decoded and published as they are parsed, publishers are created on first sight, and no code knows what the receiver was asked to emit. Enabling a message, disabling one, or moving one from 1 Hz to 50 Hz changes what appears on the bus and changes nothing in the node.

Two notes for whoever writes the estimator. Most records carry no time: record 2 is three doubles of position and nothing else, and records 8, 12 and 38 are the same for velocity, accuracy and fix quality. Only 1, 16, 41, 62, 91 and 92 carry a GPS time, so a consumer that needs one has to get it from a record that has it. And pair on arrival age, not on batch membership. GSOF batches records into transmissions, and it is tempting to treat that grouping as the answer; this node briefly stamped a transmission number on every record for exactly that. It does not survive mixed rates: at 50 Hz position against 10 Hz velocity, a same-transmission rule discards a heading that is 20 ms old and perfectly usable, leaving four positions in five with no heading at all, and it changes behaviour silently when a rate changes. Age asks the question actually being asked, whether this value still describes the same moment, and answers it the same way at any rate. nodes/map_match/fix_assembler.h is the worked example, including counting how often a pairing failed so a reconfiguration shows up rather than degrading quietly.

Publishers are created on first sight of their record so the liveliness advertisements name exactly what the receiver is really sending. The cost is that a topic that exists but has never published looks identical, in every picker in this tree, to one whose receiver went quiet.

Why the units are converted in the node

Everything published is in degrees, converted once in the node, while the library structs keep wire units so they still describe the bytes. GPS time is published as week plus milliseconds rather than converted to Unix time, because the conversion needs the leap-second offset, which only record 16 carries, and a node that guessed it would publish a timestamp wrong by 18 seconds that looks right.

Why configuration reads before it writes

configuration.mode: enforce does not rewrite the receiver on every connect. Applying an application file restarts outputs; doing it every time anything reconnected would make “the node corrected a drift” a message nobody reads. Instead the write is rare, and therefore worth something: status.outputsCorrected climbing means something else keeps changing the receiver back.

port_policy: additive is the default because the receiver may legitimately be feeding an NTRIP server or a second consumer, and a list of what this node needs is no basis for deciding those are wrong. send_command is what stops every future ICD packet being a code change, and it is gated because an arbitrary command can leave a receiver unreachable. set_output_config will not write in report_only mode because a mode a service could override would be a suggestion.

Why the mock is a second implementation

nodes/bd992_mock never sees a GSOF byte, so there is nothing to hand the decoder; it implements the topic contract directly rather than reusing publishers.cpp, and publishes five of the topics. The two can therefore drift, which is accepted: the topic table on the node page is the contract, and inspect echo against the mock and against --replay is how a divergence gets noticed. It was the first caller of map/route, map/nearest, map/track_catalog and map/track_detail anywhere in the tree.

The vehicle is a point mass on a line so that position, speed and heading cannot disagree with each other. Heading is measured to a point 5 m ahead rather than to the next vertex, because map geometry is quantised to 1e-7 degrees and an adjacent-vertex bearing jitters by tens of degrees on a straight road, which map_match reads as weaving. Curvature is measured over an 8 m baseline for the same reason, worse. Two things are deliberately not modelled: there is no elevation in either source, so the height is a constant rather than an invented terrain model, and the fix is always RTK-fixed with a fixed correction age, so nothing exercises a consumer’s degraded-accuracy path.


This site uses Just the Docs, a documentation theme for Jekyll.