bd992_bridge
Overview
A Trimble BD992 GNSS receiver on the vehicle’s Ethernet, publishing GSOF records as Cap’n Proto messages and exposing the receiver’s own configuration as zenoh services. The node is the top of three layers: gsof is the protocol, bd992 is the TCP transport and the read-before-write configuration logic, and nodes/bd992_bridge maps records onto schemas and reads the YAML. The rationale, the live-hardware findings and the open questions are in the design notes.
The receiver is a TCP server and the node connects to it. Nothing leaves the receiver until something attaches, which keeps the vehicle network quiet and makes the node the only thing that has to be running.
Running it
In the receiver’s web interface, under I/O Configuration and Port Summary, add an IP socket in TCP server mode and note its port number and which socket it is. The first three IP sockets are port indices 20, 21 and 22 on the wire; Trimble’s own prose numbers them from one. Then point configs/bd992/bd992.yaml at the receiver and probe it before running it:
./build/nodes/bd992_bridge/bd992_bridge --config configs/bd992/bd992.yaml --probe
./build/nodes/bd992_bridge/bd992_bridge --config configs/bd992/bd992.yaml --check
./build/nodes/bd992_bridge/bd992_bridge --config configs/bd992/bd992.yaml
| Option | |
|---|---|
--config <file> | The YAML below. |
--probe | Print what each stored application file contains and exit. The first thing to run, because it answers the two questions the ICD does not. |
--check | Diff the receiver against the config and exit non-zero on drift. |
--replay <file> | Replay a captured GSOF byte stream instead of connecting. |
--loop | With --replay, start again at the end of the capture. |
--replay-delay-ms <n> | With --replay, milliseconds between chunks. 0 replays as fast as the bus will take it. |
--dump-gsof <file> | Write the raw received bytes to a file. |
--debug | Verbose logging. |
The config has three sections. receiver names the host and two ports: stream_port is the socket configured to output GSOF, control_port the one that accepts command and report packets. Whether one socket can do both is still unknown (see the design notes); if you want to try, configure it for input and output and point both keys at it. reconnect_backoff_ms is tried in order and then the last value repeats.
configuration decides what the node does to the receiver. mode: enforce reads the receiver’s configuration, compares it against outputs, and writes only what differs; report_only stops after the comparison, and is the mode to use around a receiver somebody else owns. port_index is the zero-based wire index of the socket above. port_policy: additive (the default) leaves outputs the config does not mention alone and reports them; exclusive turns them off. Outputs on other ports are ignored in both directions. appfile_index is which stored application file holds the running configuration, which --probe is how you find out. recheck_interval_s re-reads and re-compares on a timer (0 checks once, at startup), and allow_raw_commands gates the send_command service.
Each entry in outputs is a record name from the GSOF record table and a named rate: off, 100hz, 50hz, 20hz, 10hz, 5hz, 2hz, 1hz, 2s, 5s, 10s, 15s, 30s, 60s, 5min, 10min or once. An unknown record name is refused at load with the full list. The list is what this node insists on, not everything available; with additive the receiver may send more, and it will be decoded and published either way.
publish sets topic_prefix (default nodes/bd992), status_key, status_interval_ms and publish_unknown_records, which puts records this build does not model on <prefix>/gsof/raw as bytes.
Without a receiver
The whole decode and publish path runs over a captured byte stream:
./build/nodes/bd992_bridge/bd992_bridge \
--config configs/bd992/replay.yaml \
--replay mock_data/data/bd992_gsof_capture.bin --loop --replay-delay-ms 20
The bytes go through the same framer, page assembler, record parsers and publishers a live receiver drives, so the topics are the real thing. --dump-gsof <file> writes the raw stream, so a minute from a vehicle becomes a fixture. Services are not offered in replay mode: there is no receiver to answer with, and a service that could only ever fail is worse than none.
bd992_mock
The capture above is 837 bytes from a receiver that was sitting still. For anything downstream of position, nodes/bd992_mock asks nodes/map_server where to drive and then drives it:
./build/nodes/map_server/map_server --config configs/map_server.yaml # first
./build/nodes/bd992_mock/bd992_mock --route 33.6866,-117.8558 --to 33.7701,-118.1937
./build/nodes/bd992_mock/bd992_mock --track "Willow Springs"
| Option | |
|---|---|
--route <lat,lon> --to <lat,lon> | Drive a road route from map/route, with posted speed limits from map/nearest. |
--track <id or name> | Drive a race track’s centreline from map/track_catalog and map/track_detail. A circuit laps; a point-to-point course runs once. |
--profile <name> | Routing cost profile. Empty means the graph’s default. |
--loop | Start again on reaching the end. |
--no-speed-limits | Skip the map/nearest pass and drive at the cruise speed. |
--check | Resolve the path, report it and exit without publishing. The fastest way to find out whether map_server can answer at all. |
--config <file> | Optional. configs/bd992/mock.yaml documents every default. |
The vehicle is a point mass on the line: position, speed and heading all come from one distance-along-path and one speed, so they cannot disagree. Speed is capped by the local curvature (v = sqrt(a_lat * R)) and by the posted limit, and braking starts before a corner rather than at it. Set vehicle.lateral_accel_mps2 to 8 to 12 for a circuit; the 3.0 default is a road car and makes for a slow lap. There is no elevation in either source, so ellipsoidHeightM is a constant and vertical velocity is zero, and the fix is always RTK-fixed with a fixed correction age.
The mock publishes five of the topics below (position_time, lat_long_height, velocity, position_type, position_sigma) on the real nodes/bd992 prefix, which is what makes nodes/map_match and the dashboard map widget work with no configuration change. It announces itself as bd992_mock, so inspect nodes always says which one you are looking at.
Do not run bd992_mock alongside a real bd992_bridge. Two publishers on one key interleave, consumers take whichever sample arrives first, and nothing is logged. The mock warns at startup if it sees another publisher, but it cannot see one that happens to be quiet just then.
Topics
One topic per GSOF record type, under <topic_prefix>/gsof/. A record named some_record in the table is published on <prefix>/gsof/some_record with schema GsofSomeRecord; unmodelled records go on <prefix>/gsof/raw as GsofRawRecord, and status_key carries a Bd992Status.
position_time lat_long_height ecef_position ecef_delta tangent_plane_delta
velocity dop_info clock_info position_vcv position_sigma sv_brief
sv_detailed receiver_serial current_time_utc attitude_info
receiver_diagnostics all_sv_brief all_sv_detailed received_base
battery_memory position_type lband_status base_position all_sv_detailed_page
ins_full_nav ins_rms code_position lat_long_msl_height second_antenna_sigma
nav_message_auth ionoguard_info ionoguard_summary
A consumer wanting position and fix quality subscribes to two topics. Nothing in the node decides which fields belong together, and nothing in it depends on which messages the receiver has enabled or at what rate: records are published as they are parsed, publishers are created on first sight of their record, and status.seen[] reports which record types have arrived and how long ago.
Everything published is in degrees. The wire is radians in records 2, 27 and 41 and degrees in record 49; the conversion happens once, in the node. Heights are above the WGS-84 ellipsoid everywhere except lat_long_msl_height, which is the one record that carries an orthometric height and the geoid model behind it. The difference is tens of metres (about 34.5 m in southern California, with MSL the larger), so feed lat_long_msl_height.mslHeightM to an altimeter and lat_long_height to a map. GPS time is published as week plus milliseconds, not converted to Unix time; consumers wanting wall-clock time have the zenoh sample stamp.
| Field | |
|---|---|
position_type.positionFixType | The whole ICD list, including the RTX and INS forms. positionFixTypeRaw always carries the wire byte, so a firmware newer than this build is reported rather than lost. |
position_type.rtkFixed | Clear is RTK float, set is RTK fixed. Not a general “is the fix good” flag: an RTX fix holds decimetre accuracy with this bit clear. |
position_type.correctionAgeS | Climbing means the correction link has gone and the fix is coasting. |
attitude_info.yawDeg | Where the vehicle points. velocity.headingDeg is a course over ground and is meaningless when stopped. |
status.seen[].ageMs | Distinguishes “the receiver is fine” from “the receiver quietly stopped sending record 27”. |
nav_message_auth.anyFailed | A satellite’s navigation message failed authentication, which is a spoofer. Nothing else in the stream shows it. |
Health
nodes/bd992/health (NodeHealth), once a second and at once on any change:
| Check | Not ok when |
|---|---|
stream | the data connection to the receiver is down (fault) |
control | the control port is down, so the configuration cannot be checked |
config | the last configuration pass disagreed with what the receiver reports |
inspect health prints them.
Services
Siblings of the topics under <topic_prefix>:
| Key | |
|---|---|
nodes/bd992/get_output_config | What the receiver is configured to emit. GETAPPFILE (65h). |
nodes/bd992/set_output_config | Change it. Reads first, writes only the difference. dryRun reports the plan without sending anything. |
nodes/bd992/apply_config | Re-run the pass from the node’s own YAML, now. |
nodes/bd992/get_receiver_info | Serial number and installed options. GETOPT (4Ah). |
nodes/bd992/send_command | Any packet type, any payload. |
inspect call nodes/bd992/get_output_config --data '{}'
inspect call nodes/bd992/set_output_config --data '{"dryRun":true,"outputs":[...]}'
send_command is refused unless allow_raw_commands is set, because an arbitrary command can leave a receiver unreachable. set_output_config will not write while the node is in report_only mode.
Tests
ctest --test-dir build -L bd992
| Target | Labels | Proves |
|---|---|---|
bd992_test_config | bd992 unit | The YAML config, exercised without a receiver: every field, the defaults, and what a bad file reports. |
bd992_test_fields | bd992 unit | Each GSOF record onto its schema, with the radians-to-degrees conversion pinned: a position published in radians is still a plausible position, thousands of kilometres away. |
Troubleshooting
--probe times out on every application file index while the stream port is delivering reports. The commands are not reaching a listener, whatever the port number says. The socket is most likely configured output-only; configure one for input and output and point control_port at it.
A topic is missing. Publishers are created on first sight of their record, so a topic that has never published looks identical, in every picker in this tree, to one whose receiver went quiet. Read status.seen[] to see what is arriving, and check the receiver’s own output list with get_output_config.
status.outputsCorrected keeps climbing. Something else keeps changing the receiver back. Correcting a drift is meant to be rare; if it is not, find the other writer or move to port_policy: exclusive on a port this node owns.
The height is tens of metres off. You are comparing an ellipsoid height with a sea-level one. Only lat_long_msl_height is orthometric.
An unknown fix type. positionFixTypeRaw has the wire byte. The enum was once trimmed to what a vehicle receiver “can” produce, and a live receiver reported rtxFastLowLatency (33) within the hour.
Two nodes on one prefix. If the map widget stutters between two positions, a bd992_mock is running alongside the bridge. inspect nodes tells them apart.
The raw topic is busy. The receiver is emitting a record type this build does not model. The bytes are on <prefix>/gsof/raw; adding the record is one row in the record table plus a struct in libs/gsof.