bd992

Overview

Talking to a Trimble BD992 over the network: a ByteStream abstraction with TCP and file-replay implementations, the reader thread that owns reconnection, the control-port request/reply client, and the desired-versus-actual comparison that decides whether anything gets written to the receiver. Everything here owns a socket, a thread or a file; nothing here knows a field offset. That is what lets the protocol half, gsof, be constexpr and checked at compile time, and it is why this target depends on gsof and gsof may never depend on this one.

It is deliberately free of zenoh and capnp too. The node, bd992_bridge, maps records onto schemas; a library that knew what a GsofLatLongHeight was could not be tested without a bus. The decisions behind the shape are in the design notes.

Public headers

Header  
bd992/byte_stream.h ByteStream: the three methods everything above is written against, never a socket.
bd992/tcp_stream.h TcpStream: a client-only ByteStream over TCP, AF_UNSPEC, every resolved address tried in turn.
bd992/replay_stream.h ReplayStream: a ByteStream over a file of captured bytes, handed out in small chunks.
bd992/stream_client.h StreamClient: the reader thread. Bytes to Framer to PageAssembler, reconnecting for as long as it runs.
bd992/control_client.h ControlClient: synchronous request and reply on the control connection; readApplicationFile, writeApplicationFile, readOptions, sendRaw.
bd992/output_config.h diff and plan_writes: two free functions over two lists, no I/O.
bd992/error.h bd992::Error: a connection error with a message and an errno. NotConnected, Timeout and Refused want different responses.

Using it

Link the CMake target bd992. Both clients take a StreamFactory, a function returning a fresh ByteStream, so the same code runs over TCP or a capture. StreamClient calls its record handler on its own thread for every record it parses:

bd992::StreamClient::StreamFactory connect = [&] {
    return bd992::TcpStream::connect(host, streamPort, connectTimeout);
};
bd992::StreamClient stream(connect, streamOptions, [&](const gsof::RawRecord& raw) {
    gsof::visit_record(raw, /* one overload per topic */);
});
stream.start();

bd992::ControlClient control(controlConnect, controlOptions);
auto file = control.readApplicationFile(appfileIndex);
auto changes = bd992::diff(file->outputs, desired, portIndex, bd992::PortPolicy::Additive);
auto records = bd992::plan_writes(changes, bd992::PortPolicy::Additive);
control.writeApplicationFile(records);

setTransmissionHandler fires once per reassembled transmission, and setByteTap is what --dump-gsof uses: every received byte, before framing.

Behaviour worth knowing

Reconnection is StreamClient’s job and is not optional. A GNSS receiver on a vehicle loses power with the ignition, and the reader thread owns the whole cycle: connect, read until the link fails, back off, connect again. Nothing above it sees a socket. The backoff list is tried in order and then the last entry repeats, capped rather than doubling forever so a receiver that comes back after an hour is picked up within seconds.

ControlClient uses a second socket, separate from the GSOF stream. The framer on the stream side only ever sees GENOUT and needs no notion of correlating replies, and a configuration read that takes the receiver a moment cannot stall position output. The client is synchronous and serialised by a mutex because it is called from zenoh service threads and a receiver answers one question at a time; the connection is opened lazily and reopened after a failure. While waiting for a reply it skips GENOUT packets, so a receiver that streams reports on the same socket cannot have a report mistaken for an answer.

diff and plan_writes have no I/O in them, which is why every interesting case (nothing drifted, a rate changed, a record went missing, the receiver has outputs nobody asked for) is a plain unit test, and why report_only and enforce share one code path that stops at different points.

The ByteStream::recvSome contract is that 0 and -1 stay distinct. A caller polling for data treats 0 as “not yet” and would spin forever on a dead link if a closed peer also reported 0.

ReplayStream’s chunk size is deliberately small by default. Handing the framer the whole file in one call tests a case that never happens on a socket; handing it seven bytes at a time tests the one that always does.

Tests

ctest --test-dir build -L bd992     # bd992_test_output_config, bd992_test_stream, bd992_test_config

bd992_test_output_config is unit: the comparison, over lists, with no receiver. bd992_test_stream is labelled net rather than unit because it opens loopback sockets against a scripted receiver, which is how the reconnect path and the control exchange are exercised without unplugging anything. bd992_test_config lives in the node and recompiles node_config.cpp so the YAML parser under test is the one the node runs.


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