Making it move · Chapter 10 · Time: 2-3 hours · Level: Intermediate · Status: Partly test-built
You read the STM32 board's byte stream by hand, write rrc_protocol.py (CRC-8/MAXIM, frame encoder, streaming parser with resync, battery and IMU decode) and its unit tests, read live battery and IMU values, beep the buzzer and send the stop frame.
The wheels, the arm servos, the IMU, the battery sensor, the buzzer and the gamepad receiver all hang off one STM32
controller board. The Jetson reaches every one of them through a single USB serial line, /dev/ttyACM0. This chapter
teaches that line from zero: what a serial port is, how the board packs its messages into frames, how a checksum
tells a good frame from a damaged one. You build ros2/rosorin_base/rosorin_base/rrc_protocol.py, the 70-line module
that every later piece of driver code uses, and try each part on the real board from a scratch folder ~/learn.
What was done on this robot: the raw capture, the decoding, rrc_protocol.py, its tests and the exclusive-open
test (2026-09-26), the stop frame (2026-09-27) and the buzzer-off frames (2026-09-29). New in this guide: the small
scripts in ~/learn, put together from that code, and the beep, which has not been played from this build's code.
Why write the protocol yourself
The factory software drove the board through Hiwonder'sros_robot_controllernode. It had a bug that could
silently disable all motor commands robot-wide (enable_receptionoverwrote itself withfalse), and it
rewrote the servo baud rate and PID settings at every start (docs/lessons.md, factory stack;
docs/motion_safety.mdrule 8). This build contains no vendor code for the board. The protocol below was
decoded on 2026-09-26 from bytes captured on this robot, with Hiwonder'sros_robot_controller_sdk.pyread only
as a reference, and checked against 788 of 788 captured frames (docs/hardware.md).
You need chapter 7 done (the board shows up as /dev/ttyACM0) and Python 3, which the stock Ubuntu image has. ROS 2
is not needed in this chapter.
Safety
- Wheels in the air before any motor frame. Put the robot on its stand so no wheel touches anything. That
includes the STOP frame at the end of this chapter: a motor frame with one wrong byte is a command to drive.- The board has no stop watchdog. On 2026-09-27 it was sent one motor command (0.3 wheel revolutions per
second) and then nothing; the wheels kept turning for the whole 5 s of the test until a zero arrived
(docs/motion_safety.md, test 1). Keep a hand near the power switch whenever you write to the board.- One program owns the port. Two programs reading
/dev/ttyACM0at once each get some of the bytes, and
neither sees whole frames. Everything in this chapter opens the port exclusively.- The buzzer can stick on. Read "Talk back: one beep" before you send a buzzer frame.
On a fresh build there is no rosorin-base service yet (it arrives in chapter 11); skip this part. On the robot as it
runs today, the board driver inside rosorin-base owns the port and your scripts would get Device or resource busy.
Stop it first, in this order:
On the robot:
systemctl is-active rosorin-base rosorin-nav rosorin-mind
touch ~/selfcare/DISABLED 2>/dev/null
sudo systemctl stop rosorin-mind 2>/dev/null
sudo systemctl stop rosorin-base
sudo fuser -v /dev/ttyACM0 || echo "no process holds ttyACM0"
Write down which units were active. The DISABLED file is the self-care switch (chapter 23): without it the
15-minute self-check sees rosorin-base down and starts it again in the middle of your experiment. The mind goes
first because it hangs in its service wait when the driver disappears under it (scripts/ops/deploy.sh).
Stopping rosorin-base also stops rosorin-nav, which has Requires=rosorin-base.service
(systemd/rosorin-nav.service). The section "Put the robot back" at the end starts everything again.
Check
The last command printsno process holds ttyACM0.
New idea: serial port
A serial port sends bytes one bit after another over a wire, at an agreed speed, the baud rate (bits per
second). Linux shows each serial port as a file under/dev: you open it, read bytes, write bytes. USB serial
devices of the "communications class" (CDC ACM) appear as/dev/ttyACM0,/dev/ttyACM1, ...; USB-to-serial
adapter chips such as the LiDAR's CH340 appear as/dev/ttyUSB0. The file belongs to groupdialout, so a user
needs that group to open it. There is no message structure on a serial line, only a stream of bytes: both ends
must agree how to cut it into messages. That agreement is the protocol.
On the robot:
lsusb | grep 1a86:55d4
ls -l /dev/ttyACM0
id -nG
Check
Recorded on this robot on 2026-09-26 (the device number can differ):Bus 001 Device 004: ID 1a86:55d4 QinHeng Electronics USB Single Serial crw-rw---- 1 root dialout 166, 0 Sep 26 23:31 /dev/ttyACM0
id -nGlistsdialoutamong your groups. The userburgerbarngot it when it was created
(scripts/prep_rootfs.shline 7).
If it fails
- No
ttyACM0: the board is not powered or its USB cable is loose. The board sits on USB port 1-2.1 and uses
the kernel'scdc_acmdriver, which the stock L4T kernel has; unlike the LiDAR, it needs no extra module.- The factory image had a
/dev/rrcalias for this device. This build has no udev rule for it; the code opens
/dev/ttyACM0directly (ros2/rosorin_base/launch/base.launch.py).
New idea: raw mode
By default Linux treats a serial port as a text terminal: it echoes input, turns carriage returns into
newlines and reacts to control characters such as Ctrl-C. For binary data every one of those changes corrupts
bytes. Raw mode turns all of it off, so each byte arrives exactly as the board sent it.
Set the port to 1,000,000 baud in raw mode, record 2 s of what the board sends, and print the first bytes in hex:
On the robot:
mkdir -p ~/learn && cd ~/learn
stty -F /dev/ttyACM0 1000000 raw -echo && timeout 2 cat /dev/ttyACM0 > rrc.bin
stat -c "%s bytes in 2s" rrc.bin
xxd rrc.bin | head -6
Check
The first capture on this robot (2026-09-26):6905 bytes in 2s 00000000: aa55 0718 0000 253d 0000 6ebd 0098 713f .U....%=..n...q? 00000010: 0040 f8bf 0020 1bc0 0000 103d 8caa 5508 .@... .....=..U. 00000020: 0700 0000 0000 0000 8daa 5507 1800 0025 ..........U....% 00000030: 3d00 006e bd00 9871 3f00 80f2 bf00 e01b =..n...q?....... 00000040: c000 0088 3d12 aa55 0718 0000 253d 0000 ....=..U....%=.. 00000050: 6ebd 0098 713f 0040 edbf 0060Your bytes differ (the readings change), but the pattern
aa55 0718repeats every 29 bytes, with a shorter
aa55 0807block in between. The board sends all of this unasked; nothing was written to it.
If it fails
0 bytes in 2sorDevice or resource busy: another program holds the port. Run
sudo fuser -v /dev/ttyACM0; on the finished robot it isboard_driver(see "Before you start").xxd: command not found: it was on the stock image of this robot (the 2026-09-26 capture used it).
New idea: bytes, hex and little-endian
A byte is 8 bits, a number from 0 to 255, written in hex as two digits00toff. Numbers bigger than one byte
take several bytes, and the two ends must agree on their order. This board sends the least significant byte
first (little-endian). The two bytes7e 31therefore mean0x317e= 12670. A 4-byte decimal number
(float32) uses the IEEE 754 format, also little-endian:00 98 71 3fis0x3f719800= 0.9437. Python's
structmodule does these conversions:'<H'is a little-endian unsigned 16-bit integer,'<f'a
little-endian float32,'<6f'six of them.
New idea: frame
A frame is one message cut out of the byte stream. A receiver that starts listening in the middle of a
message needs a way to find the next start. This board marks every start with the two bytesAA 55, then says
what the message is and how long it is, and ends it with a checksum.
Every frame, in both directions, has this layout (ros2/rosorin_base/rosorin_base/rrc_protocol.py lines 1-5):
Board bytes:
AA 55 | function (1 byte) | length n (1 byte) | payload (n bytes) | CRC-8/MAXIM over function, length, payload
Here is the first frame of the capture above, taken apart:
Board bytes:
aa 55 start marker
07 function 7 = IMU report
18 length 0x18 = 24 payload bytes
00 00 25 3d accel x float32 = 0.0403 g
00 00 6e bd accel y float32 = -0.0581 g
00 98 71 3f accel z float32 = 0.9437 g
00 40 f8 bf gyro x float32 = -1.9395 deg/s
00 20 1b c0 gyro y float32 = -2.4238 deg/s
00 00 10 3d gyro z float32 = 0.0352 deg/s
8c CRC-8/MAXIM of "07 18" + the 24 payload bytes
The next frame, aa 55 08 07 00 00 00 00 00 00 00 8d, is a gamepad report (function 8) with 7 zero bytes: no
controller input. The byte rate adds up: an IMU frame is 29 bytes at about 110 per second, a gamepad frame 12 bytes
at 20 per second, a battery frame 8 bytes once a second. That is about 3,440 bytes per second, the 6905 bytes in
2 s above.
The functions this build uses (docs/hardware.md, ros2/rosorin_base/rosorin_base/board_driver.py):
| Function | Direction | Payload | When |
|---|---|---|---|
| 0 system | board to Jetson | 04 + battery millivolts as <H (3 bytes) |
about 1 per second |
| 7 IMU | board to Jetson | <6f: ax, ay, az in g, gx, gy, gz in deg/s (24 bytes) |
about 110 per second |
| 8 gamepad | board to Jetson | <HB4b: buttons bit mask, hat, 4 stick axes (7 bytes) |
about 20 per second |
| 6 key | board to Jetson | key id, event | on a key event; the keys are not reachable on the assembled robot |
| 5 bus servo | both | servo commands and replies (chapter 13) | on request |
| 2 buzzer | Jetson to board | <HHHH: frequency Hz, on ms, off ms, repeat |
when sent |
| 3 motor | Jetson to board | 01, motor count, then per motor: wire id <B 0-3 and speed <f in revolutions per second |
when sent |
Functions 1 (LED), 4 (PWM servo), 9 (SBUS) and 10 (OLED) are listed in the factory SDK; nothing in this build uses
them.
The module lives where its ROS 2 package will be built in chapter 11. Create the folders and the empty package
marker:
On the robot:
mkdir -p ~/ros2_ws/src/rosorin_base/rosorin_base ~/ros2_ws/src/rosorin_base/test
touch ~/ros2_ws/src/rosorin_base/rosorin_base/__init__.py
Create ~/ros2_ws/src/rosorin_base/rosorin_base/rrc_protocol.py with its header and constants:
On the robot:
"""Controller-board (STM32) serial protocol, as verified on this robot (docs/hardware.md).
Frame: 0xAA 0x55 | function | length | payload[length] | crc
crc = CRC-8/MAXIM (poly 0x31 reflected -> 0x8C, init 0) over function, length, payload.
"""
import struct
FUNC_SYS = 0
FUNC_IMU = 7
FUNC_GAMEPAD = 8
SYS_BATTERY = 0x04
The docstring is the frame format from the previous section. FUNC_SYS, FUNC_IMU and FUNC_GAMEPAD are the
function numbers of the three reports the board streams; SYS_BATTERY is the first payload byte of a battery
report.
The scripts in ~/learn import the module from this folder. Each one starts with the same two lines (the same
pattern scripts/test_fw_watchdog.py uses):
On the robot:
import sys
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base') # where rrc_protocol.py lives
New idea: checksum and CRC
A checksum is a small number computed from the bytes of a message and sent with it. The receiver computes it
again; if the two differ, a byte was damaged or lost, and the frame is thrown away. A plain sum misses many
errors (two swapped bytes have the same sum). A CRC (cyclic redundancy check) mixes every bit into the
result through shifts and XORs, so swaps and most bursts of damaged bits change it. Many CRC variants exist; they
differ in the polynomial (which bits get mixed), the start value, and the bit order. This board uses
CRC-8/MAXIM: 8 bits, polynomial 0x31 processed least-significant bit first (which turns it into 0x8C),
start value 0.
Add the function below the constants in rrc_protocol.py:
On the robot:
def crc8_maxim(data: bytes) -> int:
c = 0
for b in data:
c ^= b
for _ in range(8):
c = (c >> 1) ^ 0x8C if c & 1 else c >> 1
return c
For each byte: XOR it into the running value c, then 8 times shift c one bit right, and whenever the bit that
falls out was 1, XOR in 0x8C. The CRC covers function, length and payload, not the AA 55 start marker.
Try it on the battery report captured on 2026-09-26. Create ~/learn/crc_check.py:
On the robot:
import sys
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base') # where rrc_protocol.py lives
from rosorin_base.rrc_protocol import crc8_maxim
body = bytes.fromhex('0003047e31') # function 0, length 3, payload 04 7e 31 (battery, captured 2026-09-26)
c = 0
for b in body: # the same steps as crc8_maxim, printed after each byte
c ^= b
for _ in range(8):
c = (c >> 1) ^ 0x8C if c & 1 else c >> 1
print(f'after {b:02x}: crc {c:02x}')
print('crc8_maxim(body) =', hex(crc8_maxim(body)))
print('check value =', hex(crc8_maxim(b'123456789')))
On the robot:
cd ~/learn && python3 crc_check.py
Check
Computed with the repository'scrc8_maximfrom the captured bytes:after 00: crc 00 after 03: crc e2 after 04: crc 34 after 7e: crc 38 after 31: crc 9c crc8_maxim(body) = 0x9c check value = 0xa1
9cis the CRC the board sends after a battery report with payload04 7e 31.0xa1for the ASCII text
123456789is the published check value of CRC-8/MAXIM; when an implementation gives that value, its
polynomial, bit order and start value are right.
If it fails
ModuleNotFoundError: No module named 'rosorin_base': thesys.pathline names another folder, or
rosorin_base/__init__.pyis missing.- A wrong check value usually means
0x31was used where0x8Cbelongs, or the shift went left.
Add encode_frame below crc8_maxim:
On the robot:
def encode_frame(func: int, payload: bytes) -> bytes:
body = bytes([func, len(payload)]) + bytes(payload)
return b'\xaa\x55' + body + bytes([crc8_maxim(body)])
body is function, length and payload; the frame is AA 55, the body, and the CRC of the body. Create
~/learn/encode_check.py, which builds the battery frame and the motor frame that stops all four wheels:
On the robot:
import struct
import sys
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base')
from rosorin_base.rrc_protocol import encode_frame
print('battery:', encode_frame(0, bytes.fromhex('047e31')).hex(' '))
# motor frame: sub-command 0x01, 4 motors, then (wire id 0..3, speed float32) per motor; all zero = STOP
stop = encode_frame(3, bytes([0x01, 4]) + b''.join(struct.pack('<Bf', i, 0.0) for i in range(4)))
print('stop: ', stop.hex(' '))
print('matches test_motion.py:', stop.hex() == 'aa5503160104000000000001000000000200000000030000000007')
On the robot:
cd ~/learn && python3 encode_check.py
Check
battery: aa 55 00 03 04 7e 31 9c
stop: aa 55 03 16 01 04 00 00 00 00 00 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00 07
matches test_motion.py: TrueThe stop frame is byte for byte the one in
ros2/rosorin_base/test/test_motion.py, which was sent on
2026-09-27 and stopped the wheels in motion test 1. Function03, length0x16= 22: sub-command01
(set speeds),04motors, then four times a wire id (00to03) and a float32 speed of00 00 00 00.
A read from the port returns whatever bytes have arrived: half a frame, three frames and a bit, or a run of bytes
from the middle of a frame you missed. The parser keeps a buffer and cuts whole, checked frames out of it.
Add the FrameParser class below encode_frame:
On the robot:
class FrameParser:
"""Incremental parser: feed() bytes, get back (function, payload) for CRC-valid frames."""
def __init__(self):
self.buf = bytearray()
self.crc_errors = 0
def feed(self, data: bytes):
self.buf += data
out = []
while True:
i = self.buf.find(b'\xaa\x55')
if i < 0:
del self.buf[:-1]
return out
if i:
del self.buf[:i]
if len(self.buf) < 5:
return out
n = self.buf[3]
if len(self.buf) < 5 + n:
return out
body = bytes(self.buf[2:4 + n])
crc = self.buf[4 + n]
if crc == crc8_maxim(body):
out.append((body[0], body[2:]))
del self.buf[:5 + n]
else:
self.crc_errors += 1
del self.buf[:2]
What feed() does, in order:
AA 55. If there is none, throw the buffer away except its last byte (it may be the AA of a start55 has not arrived yet), and return.n and wait until all 5 + n bytes are there.(function, payload) and remove the frame from the buffer.Step 7 is the important one. AA 55 also occurs inside payloads (a float can contain those two bytes). A parser that
trusts the first marker it sees, or skips a whole "frame" after a bad one, loses real frames or invents fake ones.
Dropping only the two marker bytes means the next real marker is still in the buffer (docs/lessons.md
2026-09-26: "Never assume the first header found is real").
Feed the parser the first 92 bytes of the capture. Create ~/learn/parse_capture.py:
On the robot:
import sys
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base')
from rosorin_base.rrc_protocol import FrameParser
# the first 92 bytes read from /dev/ttyACM0 on 2026-09-26 (the xxd dump in the command log)
CAPTURE = bytes.fromhex(
'aa5507180000253d00006ebd0098713f0040f8bf00201bc00000103d8c'
'aa550807000000000000008d'
'aa5507180000253d00006ebd0098713f0080f2bf00e01bc00000883d12'
'aa5507180000253d00006ebd0098713f0040edbf0060')
p = FrameParser()
for func, payload in p.feed(CAPTURE):
print('function', func, 'length', len(payload), payload.hex(' '))
print('crc errors:', p.crc_errors, '| bytes waiting for the rest of a frame:', len(p.buf))
bad = bytearray(CAPTURE)
bad[28] ^= 0xFF # damage the CRC byte of the first frame
p = FrameParser()
print('damaged:', [func for func, _ in p.feed(bytes(bad))], '| crc errors:', p.crc_errors)
On the robot:
cd ~/learn && python3 parse_capture.py
Check
Computed with the repository'sFrameParserfrom the bytes captured on 2026-09-26:function 7 length 24 00 00 25 3d 00 00 6e bd 00 98 71 3f 00 40 f8 bf 00 20 1b c0 00 00 10 3d function 8 length 7 00 00 00 00 00 00 00 function 7 length 24 00 00 25 3d 00 00 6e bd 00 98 71 3f 00 80 f2 bf 00 e0 1b c0 00 00 88 3d crc errors: 0 | bytes waiting for the rest of a frame: 22 damaged: [8, 7] | crc errors: 1Two IMU frames and a gamepad frame come out whole. The last 22 bytes are the start of a fourth frame that the
head -6cut off; the parser keeps them and waits. With the first frame's CRC damaged, that frame is dropped,
counted, and the parser finds the next two frames anyway.
New idea: IMU
An IMU (inertial measurement unit) measures acceleration along three axes (accelerometer) and rotation rate
around three axes (gyroscope). The board's IMU is an MPU6050. At rest the accelerometer reads gravity, about 1 g
pointing up, and the gyroscope should read zero, but it does not: each axis has an offset, the bias. On this
robot the gyro read about -1.9, -2.2 and +0.05 deg/s while standing still, and the bias was different after
the next boot ((-1.89, -2.23) against (-1.69, -1.77) deg/s,docs/lessons.md). Chapter 11 measures it at every
start and subtracts it.
Add the two decoders at the end of rrc_protocol.py:
On the robot:
def decode_imu(payload: bytes):
"""-> (ax, ay, az) in g, (gx, gy, gz) in deg/s."""
ax, ay, az, gx, gy, gz = struct.unpack('<6f', payload)
return (ax, ay, az), (gx, gy, gz)
def decode_battery_mv(payload: bytes):
"""-> battery millivolts, or None if not a battery report."""
if len(payload) == 3 and payload[0] == SYS_BATTERY:
return struct.unpack('<H', payload[1:])[0]
return None
decode_imu returns the board's own units, g and deg/s. ROS messages use m/s^2 and rad/s; the driver converts them
in chapter 11. decode_battery_mv returns None for any system report that is not a battery report, so callers can
test for it.
Create ~/learn/decode_check.py:
On the robot:
import sys
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base')
from rosorin_base.rrc_protocol import decode_battery_mv, decode_imu
print('battery mV:', decode_battery_mv(bytes.fromhex('047e31')))
print('other sys report:', decode_battery_mv(bytes.fromhex('0101')))
(ax, ay, az), (gx, gy, gz) = decode_imu(bytes.fromhex('0000253d00006ebd0098713f0040f8bf00201bc00000103d'))
print(f'accel g: {ax:+.4f} {ay:+.4f} {az:+.4f}')
print(f'gyro deg/s: {gx:+.4f} {gy:+.4f} {gz:+.4f}')
On the robot:
cd ~/learn && python3 decode_check.py
Check
battery mV: 12670
other sys report: None
accel g: +0.0403 -0.0581 +0.9437
gyro deg/s: -1.9395 -2.4238 +0.035212670 mV is 12.67 V, the value recorded with the charger plugged in on 2026-09-26. The accelerometer magnitude
is about 0.946 g, not 1.000 g: the raw IMU is off by about 5 %. Chapter 11 applies the calibration that fixes it
(ros2/rosorin_base/config/imu_calibration.yaml).
This is the complete ~/ros2_ws/src/rosorin_base/rosorin_base/rrc_protocol.py, identical to
ros2/rosorin_base/rosorin_base/rrc_protocol.py in the repository:
On the robot:
"""Controller-board (STM32) serial protocol, as verified on this robot (docs/hardware.md).
Frame: 0xAA 0x55 | function | length | payload[length] | crc
crc = CRC-8/MAXIM (poly 0x31 reflected -> 0x8C, init 0) over function, length, payload.
"""
import struct
FUNC_SYS = 0
FUNC_IMU = 7
FUNC_GAMEPAD = 8
SYS_BATTERY = 0x04
def crc8_maxim(data: bytes) -> int:
c = 0
for b in data:
c ^= b
for _ in range(8):
c = (c >> 1) ^ 0x8C if c & 1 else c >> 1
return c
def encode_frame(func: int, payload: bytes) -> bytes:
body = bytes([func, len(payload)]) + bytes(payload)
return b'\xaa\x55' + body + bytes([crc8_maxim(body)])
class FrameParser:
"""Incremental parser: feed() bytes, get back (function, payload) for CRC-valid frames."""
def __init__(self):
self.buf = bytearray()
self.crc_errors = 0
def feed(self, data: bytes):
self.buf += data
out = []
while True:
i = self.buf.find(b'\xaa\x55')
if i < 0:
del self.buf[:-1]
return out
if i:
del self.buf[:i]
if len(self.buf) < 5:
return out
n = self.buf[3]
if len(self.buf) < 5 + n:
return out
body = bytes(self.buf[2:4 + n])
crc = self.buf[4 + n]
if crc == crc8_maxim(body):
out.append((body[0], body[2:]))
del self.buf[:5 + n]
else:
self.crc_errors += 1
del self.buf[:2]
def decode_imu(payload: bytes):
"""-> (ax, ay, az) in g, (gx, gy, gz) in deg/s."""
ax, ay, az, gx, gy, gz = struct.unpack('<6f', payload)
return (ax, ay, az), (gx, gy, gz)
def decode_battery_mv(payload: bytes):
"""-> battery millivolts, or None if not a battery report."""
if len(payload) == 3 and payload[0] == SYS_BATTERY:
return struct.unpack('<H', payload[1:])[0]
return None
Compare your file with the repository from your laptop:
On your laptop:
cd ~/CCode/rosorin-pro
ssh rosorin-wifi cat ros2_ws/src/rosorin_base/rosorin_base/rrc_protocol.py | diff - ros2/rosorin_base/rosorin_base/rrc_protocol.py && echo same
Check
same. Any other output lists the lines where your file differs.
A unit test is a small function that calls your code with known input and asserts the result. The three tests for
this module use a real battery frame from the robot, a damaged frame, and a frame split across two reads. Create
~/ros2_ws/src/rosorin_base/test/test_rrc_protocol.py:
On the robot:
from rosorin_base.rrc_protocol import FrameParser, crc8_maxim, decode_battery_mv
def frame(func, payload):
body = bytes([func, len(payload)]) + payload
return b'\xaa\x55' + body + bytes([crc8_maxim(body)])
def test_battery_frame_from_robot():
# real frame captured 2026-09-26: func 0, payload 04 7e 31
p = FrameParser()
out = p.feed(frame(0, bytes.fromhex('047e31')))
assert out == [(0, bytes.fromhex('047e31'))]
assert decode_battery_mv(out[0][1]) == 12670
def test_bad_crc_rejected_and_resync():
good = frame(8, bytes(7))
bad = bytearray(good); bad[-1] ^= 0xFF
p = FrameParser()
out = p.feed(bytes(bad) + b'\x00\x13' + good)
assert out == [(8, bytes(7))] and p.crc_errors == 1
def test_split_across_reads():
f = frame(7, bytes(24))
p = FrameParser()
assert p.feed(f[:10]) == []
assert p.feed(f[10:]) == [(7, bytes(24))]
test_battery_frame_from_robot: the real battery payload goes in, the same payload and 12670 mV come out.test_bad_crc_rejected_and_resync: a gamepad frame with a broken CRC, two junk bytes, then a good copy. Only thetest_split_across_reads: the first 10 bytes of an IMU frame give nothing; the rest completes the frame.On the robot:
cd ~/ros2_ws/src/rosorin_base
python3 -m pytest -q test/test_rrc_protocol.py
Check
Recorded on this robot on 2026-09-26:... [100%] 3 passed in 0.02s
If it fails
ModuleNotFoundError: No module named 'rosorin_base': run it from~/ros2_ws/src/rosorin_baseand use
python3 -m pytest, notpytest. The-mform puts the current folder on Python's import path, so
rosorin_baseis found there.No module named pytest: on this robot pytest was already there after chapter 9 (it ran on 2026-09-26
without a separate install); the record does not say which package brought it.
Now open the real port from Python. ~/learn/rrc_read.py reads for a few seconds, prints each battery report and one
IMU reading per second, and counts frames per function. Its open_raw_readonly is copied unchanged from
ros2/rosorin_base/rosorin_base/board_reader.py, the read-only node that ran on this robot on 2026-09-26.
Create ~/learn/rrc_read.py:
On the robot:
"""Read the controller board for a few seconds and print what it reports. Read-only: never writes to it."""
import collections
import fcntl
import os
import sys
import termios
import time
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base')
from rosorin_base.rrc_protocol import FUNC_IMU, FUNC_SYS, FrameParser, decode_battery_mv, decode_imu
def open_raw_readonly(dev, baud):
fd = os.open(dev, os.O_RDONLY | os.O_NOCTTY)
fcntl.ioctl(fd, termios.TIOCEXCL) # single owner: further opens get EBUSY
a = termios.tcgetattr(fd)
a[0] = 0 # iflag
a[1] = 0 # oflag
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
a[3] = 0 # lflag
a[4] = a[5] = baud
a[6][termios.VMIN] = 1
a[6][termios.VTIME] = 0
termios.tcsetattr(fd, termios.TCSANOW, a)
return fd
secs = float(sys.argv[1]) if len(sys.argv) > 1 else 5.0
fd = open_raw_readonly('/dev/ttyACM0', termios.B1000000)
parser, count, t0, shown = FrameParser(), collections.Counter(), time.time(), 0
while time.time() - t0 < secs:
for func, payload in parser.feed(os.read(fd, 512)):
count[func] += 1
if func == FUNC_SYS and decode_battery_mv(payload) is not None:
print('battery', decode_battery_mv(payload), 'mV')
elif func == FUNC_IMU and len(payload) == 24 and time.time() - t0 >= shown:
(ax, ay, az), (gx, gy, gz) = decode_imu(payload)
print(f'imu accel g {ax:+.3f} {ay:+.3f} {az:+.3f} gyro deg/s {gx:+.2f} {gy:+.2f} {gz:+.2f}')
shown += 1 # one IMU line per second
os.close(fd)
print('frames per second by function:', {f: round(n / secs, 1) for f, n in sorted(count.items())},
'| crc errors:', parser.crc_errors)
How the port is opened:
O_RDONLY: this script cannot write to the board, so it cannot move anything.O_NOCTTY: the port must not become the controlling terminal of the process (a terminal could send it signals).TIOCEXCL: exclusive mode. Any later open() of the port by another process fails with EBUSYtermios attributes: input, output and local flags 0 is raw mode; CS8 | CREAD | CLOCAL is 8 data bits, receiverB1000000; VMIN 1, VTIME 0 makes os.read wait until at least oneOn the robot:
cd ~/learn && python3 rrc_read.py 5
Check
You see lines of this shape (values from this robot: the 2026-09-26 capture and the rest measurement the same
night):imu accel g +0.040 -0.058 +0.944 gyro deg/s -1.94 -2.42 +0.04 battery 12670 mV ... frames per second by function: {0: 0.8, 7: 110.5, 8: 20.0} | crc errors: 0
- Function 7 (IMU) close to 110 per second. A 6 s capture on 2026-09-26 held 663 IMU, 120 gamepad and 5
battery frames, with 788 of 788 CRCs valid.- Accelerometer z about +0.94 g with the robot level; gyro about -1.9, -2.2, +0.05 deg/s at rest.
- The battery value depends on the charge: 12670 mV on the charger on 2026-09-26, 11130 mV after about 2 hours
unplugged on 2026-09-30, 10843 mV in the battery log on 2026-10-01 (docs/hardware.md, command log).- CRC errors 0.
Not test-built
rrc_read.pywas put together for this guide fromboard_reader.pyand the measuring script that ran on
2026-09-26 (/tmp/imu_stats.pyin the command log). The same port settings and parser ran on the robot; this
exact file did not. The expected numbers above are from those runs.
Start a 20 s read in one ssh session:
On the robot:
cd ~/learn && python3 rrc_read.py 20
While it runs, try to open the port from a second ssh session:
On the robot:
python3 -c "import os; os.open('/dev/ttyACM0', os.O_RDONLY|os.O_NOCTTY)" 2>&1 | tail -1
Check
Recorded on 2026-09-26 with the read-only node holding the port:OSError: [Errno 16] Device or resource busy: '/dev/ttyACM0'
Why the exclusive lock
On 2026-09-26 a measuring script and a forgotten copy of the reader both had the port open. Neither failed. Each
got part of the byte stream, and the measuring script counted 1204 IMU frames in 20 s: 60 per second instead of
110 (docs/lessons.md: "Two readers on one serial port silently split the frames"). Nothing reported an error;
only the rate gave it away. Since then every program in this build opens the board withTIOCEXCL, and a
second opener fails at once withEBUSY.
If it fails
rrc_read.pyitself fails withDevice or resource busy: something else holds the port.sudo fuser -v /dev/ttyACM0names it.- IMU around 60 per second instead of 110: two readers. Find the other one with
fuser.- The script stops after printing nothing:
os.readwaits for bytes, and a board that sends nothing keeps it
waiting. Check the power switch andls -l /dev/ttyACM0; Ctrl-C ends the script.
Writing needs the port opened read-write (O_RDWR). ~/learn/rrc_send.py sends one of three frame sets: a beep, the
two "buzzer off" frames, or the stop frame three times. Its port settings are the ones the 2026-09-29 buzzer
recovery used on this robot, plus TIOCEXCL. Create ~/learn/rrc_send.py:
On the robot:
"""Send frames to the controller board. Usage: rrc_send.py beep | off | stop"""
import fcntl
import os
import struct
import sys
import termios
import time
sys.path.insert(0, '/home/burgerbarn/ros2_ws/src/rosorin_base')
from rosorin_base.rrc_protocol import encode_frame
FUNC_BUZZER = 2
FUNC_MOTOR = 3
STOP = encode_frame(FUNC_MOTOR, bytes([0x01, 4]) + b''.join(struct.pack('<Bf', i, 0.0) for i in range(4)))
assert STOP.hex() == 'aa5503160104000000000001000000000200000000030000000007' # test/test_motion.py
FRAMES = {
# buzzer payload <HHHH: frequency Hz, on ms, off ms, repeat. Never an off time of 0 (2026-09-29 incident).
'beep': [encode_frame(FUNC_BUZZER, struct.pack('<HHHH', 1800, 90, 100, 1))],
# the two frames that silenced the stuck buzzer on 2026-09-29
'off': [encode_frame(FUNC_BUZZER, struct.pack('<HHHH', 0, 0, 0, 0)),
encode_frame(FUNC_BUZZER, struct.pack('<HHHH', 1000, 0, 0, 0))],
'stop': [STOP, STOP, STOP],
}
frames = FRAMES[sys.argv[1]]
fd = os.open('/dev/ttyACM0', os.O_RDWR | os.O_NOCTTY)
fcntl.ioctl(fd, termios.TIOCEXCL)
a = termios.tcgetattr(fd); a[0] = a[1] = a[3] = 0; a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL; a[4] = a[5] = termios.B1000000
termios.tcsetattr(fd, termios.TCSANOW, a)
for f in frames:
os.write(fd, f)
print('sent', f.hex(' '))
time.sleep(0.05)
os.close(fd)
The beep is the frame the driver's tone(1800, 90) would write: 1800 Hz, 90 ms on, 100 ms off, once
(ros2/rosorin_base/rosorin_base/board_driver.py, tone).
The buzzer incident
On 2026-09-29 the driver sent tones with an off time of 0 (<HHHH freq, on, 0, 1>). Afterwards the buzzer
sounded without stopping. It stopped only when the driver was stopped and two raw frames were written:
(0, 0, 0, 0)and then(1000, 0, 0, 0). The likely cause, not verified, is that the firmware loops on an off
time of 0. Since then the driver uses a 100 ms off time, like the factory SDK examples, and all its sounds are off
unless the parameterpad_soundsis true (docs/motion_safety.md). Havepython3 rrc_send.py offtyped and
ready in a second session before you send the beep.
On the robot:
cd ~/learn && python3 rrc_send.py beep
Check
The script prints the frame (computed from the code) and the robot gives one short tone:sent aa 55 02 08 08 07 5a 00 64 00 01 00 faPayload
08 07= 1800 Hz,5a 00= 90 ms on,64 00= 100 ms off,01 00= once.
If it fails
- The tone does not stop:
python3 rrc_send.py off. It prints the two frames
aa 55 02 08 00 00 00 00 00 00 00 00 d8andaa 55 02 08 e8 03 00 00 00 00 00 00 c6. On 2026-09-29 these two
frames silenced the stuck buzzer ("buzzer-off frames sent").- No sound and no error: the board does not answer buzzer frames, so the script cannot tell whether it was
heard. Runpython3 rrc_read.py 2: if frames still arrive, the link is fine.
Not test-built
No beep with a 100 ms off time has been played on this robot from this build's code; the driver's sounds have
been off since the incident ("test one tone with the owner present first",docs/motion_safety.md). On
2026-08-16 a beep of 1900 Hz, 0.1 s on, 0.1 s off, 3 repeats was sent through the factory software, which uses
the same<HHHH>layout; the command log shows the message going out, not whether it was heard.
Safety
Robot on its stand, all four wheels free, power switch within reach. The stop frame commands 0 revolutions per
second on all four motors. With the wheels already still, nothing visible happens; that is the expected result.
For comparison, this is the frame that drove the wheels in motion test 1 on 2026-09-27 (0.3 revolutions per second
on all four motors). It is shown to read, not to send: 9a 99 99 3e is 0.3 as float32.
Board bytes:
aa 55 03 16 01 04 00 9a 99 99 3e 01 9a 99 99 3e 02 9a 99 99 3e 03 9a 99 99 3e 46
Send the stop frame three times, the way stop_motors does in chapter 11:
On the robot:
cd ~/learn && python3 rrc_send.py stop
python3 rrc_read.py 2
Check
Three lines, each the frame fromros2/rosorin_base/test/test_motion.py:sent aa 55 03 16 01 04 00 00 00 00 00 01 00 00 00 00 02 00 00 00 00 03 00 00 00 00 07The wheels do not move.
rrc_read.py 2still prints IMU and battery values: writing to the board did not
disturb its reports.
If it fails
AssertionErrorat the start: the stop frame the script built is not the one from the unit test. Do not
send anything; comparerrc_protocol.pywith the repository (the diff above).- A wheel turns: switch the robot off at the power switch. Then compare your files with the repository before
you write to the board again.
Writing raw frames by hand stops here. From chapter 11 on, only the board driver writes to the port, and a separate
small program, stop_motors, sends this stop frame whenever the driver is gone.
On a fresh build there is nothing to restore. If you stopped services in "Before you start", start them again: the
base first, then navigation and the mind, then the self-care switch back on.
On the robot:
sudo systemctl start rosorin-base
sleep 15
sudo systemctl start rosorin-nav rosorin-mind
rm -f ~/selfcare/DISABLED
systemctl is-active rosorin-base rosorin-nav rosorin-mind
Start only the units that were active before (leave out rosorin-nav or rosorin-mind if they were not).
Check
Oneactiveper unit you started.sudo fuser -v /dev/ttyACM0now namesboard_driver.
If it fails
rosorin-basekeeps restarting and its journal (journalctl -u rosorin-base -n 30) showsDevice or resource busy: one of your scripts still holds the port. Close it, or find it withfuser.
python3 -m pytest -q test/test_rrc_protocol.py in ~/ros2_ws/src/rosorin_base prints 3 passed.rrc_protocol.py is the same as the repository's (same from the diff).python3 rrc_read.py 5 shows battery millivolts, about 110 IMU frames per second, and 0 CRC errors.Device or resource busy.xxd output.Where this comes from
ros2/rosorin_base/rosorin_base/rrc_protocol.py,ros2/rosorin_base/test/test_rrc_protocol.py,
ros2/rosorin_base/test/test_motion.py(stop frame),ros2/rosorin_base/rosorin_base/board_reader.py
(open_raw_readonly),ros2/rosorin_base/rosorin_base/board_driver.py(tone, function numbers),
scripts/test_fw_watchdog.py,docs/hardware.md(controller board, power),docs/motion_safety.md(reference
facts, test 1, buzzer incident),docs/lessons.md2026-09-26 (two readers, resync, gyro bias), commitbf5cac1.
Command log: first raw read and decode 2026-09-26 23:31-23:32, package and tests 23:33, exclusive-open test
23:48, motion test 1 2026-09-27 07:33, buzzer recovery 2026-09-29 20:43, factory beep 2026-08-16 00:00.
The CRC trace, the parser output on the 92 captured bytes, the decode values and the beep and buzzer-off frame
bytes were computed with the repository's own code on 2026-10-07; nothing was run on the robot for this chapter.