Xsens MTi-610 notes

What the XBus protocol does that a reading of the manual would not predict, where the manual is silent, and what is waiting for a device. The pages this backs are mti610_bridge, xbus and mti610.

What an MTi-610 is, and is not

The 610 is the IMU member of the 600-series and has no orientation filter. Two independent places in the LLCP say so: Table 17’s product columns, and the default-configuration table in section 4.2, which groups “MTi-1/10/100/610 IMU” with Delta_q, Delta_v and Mag Field and gives it no quaternion. That is why there is no quaternion topic and why a busy raw topic on a 610 means the device is not a 610.

Why the packet header rides on every message

This is the one place the design departs from nodes/bd992_bridge, and deliberately. A GSOF record carries its own gpsTimeMs, so publishing one record per topic with no shared context loses nothing. An MTData2 item does not. The packet counter, the sample time and the status word are properties of the packet, shared by every measurement beside them. Putting them on their own topics would leave a consumer unable to say which acceleration belongs to which instant except by arrival order, the pair-by-arrival guess libs/map_match has to make for GNSS and does not have to make here. So every message carries XbusSampleHeader, and everything else about the BD992 model is kept: one topic per identifier, publishers created on first sight, no fusing, no batching.

The device has two states, and one port

A BD992 configures itself over a second socket while the first keeps streaming. An MTi has one port and two mutually exclusive states: it answers configuration messages only in Config, and emits MTData2 only in Measurement. So whoever owns the port owns the handshake, or the two race for the same bytes. StreamClient runs both on its reader thread, and reconfiguration is a request acted on at the top of the next loop. This is why recheck_interval_s defaults to a minute and not a second, and why every service call costs data.

An unsolicited WakeUp means the device reset. It must be answered within 500 ms or the device enters Measurement with its stored configuration, which is exactly what the handshake spent its time replacing. Miss this and the node keeps publishing, with the wrong outputs at the wrong rates, and nothing anywhere says so. mti610_test_stream has a fake device that brown-outs mid-stream to keep this honest.

The fixed-point byte order

fp16.32 is not a 48-bit big-endian integer. The value is round(v * 2^32) as an int64, of which the low six bytes are sent in the order [b3,b2,b1,b0,b5,b4]: the fractional part first, then the integer part. A plain six-byte big-endian read compiles, runs, and returns a plausible wrong number for every value; 9.81 m/s² reads back as −12451.84.

Round-tripping does not catch this. An encoder and decoder that share the same wrong byte order agree perfectly. What catches it is the cross-check in libs/xbus/tests/test_fixed_point.cpp: encode one value as float32 and as fp16.32 and require the decodes to agree. There is no byte order the two can be wrong in together. Removing the swizzle breaks the build at four static_asserts, which was verified.

Message ids alias

ReqOutputConfiguration and SetOutputConfiguration are both 0xC0; ReqBaudrate and SetBaudrate are both 0x18. Around thirty such pairs exist in the SDK’s xsxbusmessageid.h. A message id alone does not name a message, and only the length tells the pair apart, which is why describe_host_message(id, hasPayload) takes two arguments.

The preamble is an ordinary byte

0xFA appears inside payloads. There is no escaping and no trailer, so a preamble byte inside an accelerometer reading is ordinary, and an accelerometer reading passes through 0xFA several times a second. The framer resyncs by dropping exactly one byte and revalidating, never by scanning ahead to the next preamble, because scanning would skip real messages. A corollary that looks like a bug the first time you see it: a false preamble claiming a long payload makes the framer wait rather than guess, and everything behind it arrives at once when the candidate fails its checksum.

The output list is replaced, never edited

SetOutputConfiguration replaces the whole list. XBus has no way to change one output. So port_policy: additive, “leave what I did not mention alone”, means the node re-sends those entries. Getting it backwards switches off somebody else’s data with no error anywhere. This is the opposite of how the BD992’s APPFILE works, and it is what shapes mti610/output_config.h.

The device ignores the metadata frequency

The device answers 0xFFFF for the frequency of packet metadata whatever was asked. A configuration check comparing raw numbers would find drift on every pass, rewrite, and find it again a minute later, forever. normalise_frequency() exists for this; so does the test named after it. The shipped config writes rate: max for those entries to match what the device will report.

Where the protocol knowledge came from

Two independent sources, which is the point. The LLCP (MT Low Level Communication Protocol Documentation, MT0101P rev 2019.C) for prose and payload layouts, and the SDK’s own headers for every numeric constant: xstypes/xsxbusmessageid.h, xstypes/xsdataidentifier.h, xstypes/xsmessage.h, extracted from xsens-xme-sdk and used as a constants oracle only. Nothing linked, no code copied.

Five complete messages that Xsens printed with their checksums are in libs/xbus/tests/golden/golden_messages.h, and make_message() reproduces all five byte for byte. Two of the synthetic checksums in the first draft of that file were wrong; the vendor vectors are what caught it.

The SDK also ships two multi-megabyte .mtb logs, which are raw XBus streams. Both frame end to end with every byte accounted for, 15,325 messages, 15,321 of them extended-length, and the C++ framer and an independent Python one agree exactly. That is what extended-length framing rests on; before it, the long form was covered only by vectors written here. Two small complete messages lifted out of those logs settle the FirmwareRev layout and the output-configuration entry layout, both of which were previously only a reading of a table. The logs are from a Bodypack, not an MTi, which does not matter for the claim being made: the frame is common to every Xsens device. They are Xsens-licensed and not checked in; libs/xbus/tests/golden/tools/verify_sdk_corpus.py re-runs the check, and libs/xbus/tests/golden/README.md has the full provenance argument.

Two gaps in the documentation

Both are handled by refusing to guess.

SetPortConfig’s word layout is Figure 2 of the LLCP, an image with no accompanying text. The bit layout is unknown, so PortConfig words are read and round-tripped opaquely and never composed. The node therefore never changes the device’s baud rate; it opens the port at whatever the YAML says. Sourcing the MTi-600 HW Integration Manual would close this.

LLCP Table 15, the CONFIGURATION message for the 600-series, is internally inconsistent: an 8-byte field at offset 98 and a 12-byte field at offset 102, which overlap. Nothing here parses past offset 96.

Deferred to hardware

As of 2026-09-14 no MTi has been connected to this code. None of the following can be proved by anything in the test suite, in rough order of how much it would change:

  • every command encoding; the framing rests on seven vendor vectors and 15,325 vendor messages, but the payload content of SetOptionFlags rests on the LLCP’s table alone, and nothing has confirmed that a 610 accepts any of these in sequence
  • axis convention and sign, against a known physical orientation
  • magnetic field scaling, where “arbitrary units” is all the LLCP offers
  • real rates versus requested; the device clamps rather than refusing, the node warns when the echo differs, and nobody has seen it happen
  • whether AccelerationHR and RateOfTurnHR arrive in their own packets as the LLCP implies, and what header they carry
  • where SampleTimeFine wraps on a 600-series; documented for the 1-series (0xFFFFFFFF) and the 10/100-series (one day), unstated for the 600s, and published raw for that reason
  • whether the Configuration message a 610 sends matches Table 15’s self-contradictory offsets
  • the SetPortConfig word layout
  • baud rates above 115200; a pty ignores baud, and the macOS IOSSIOSPEED path above 230400 has never carried a byte
  • a real capture via --dump-xbus, promoting the synthetic goldens with libs/xbus/tests/golden/tools/gen_golden.py and deleting the SYNTHETIC labels that no longer apply

A capture of an IMU is not location-bearing the way a GNSS capture is, so the rule that keeps BD992 captures out of the tree does not apply. But a capture taken while the device is bolted to a moving vehicle is a trajectory. Look at what is in it before committing it.


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