简体中文 | English
This repository contains the driver code for the Nooploop AOA Follow Me product. It provides:
- Pure C driver (
include/+src/): no third-party dependencies, ready to be integrated into MCU/embedded projects. It handles the "encoding" and "parsing" of the device communication protocol. - ROS1 / ROS2 driver packages (
app/+msg/+launch/): a layer on top of the pure C driver that wraps serial I/O and topic conversion. Just clone it into a ROS workspace to use it.
fm_driver/
├── include/ # Public headers
│ ├── fm_driver_for_user.h # User-side API (usually the only one you need)
│ └── fm_driver_data.h # Message IDs and structs (included by the header above)
├── src/ # Pure C driver implementation (compile these too)
│ ├── fm_crc.c/.h # CRC checksum
│ ├── fm_frame.c/.h # Frame assembly/disassembly
│ ├── fm_driver.c # Encoding/parsing main logic
│ ├── fm_driver_raw.h # Protocol-layer structs and conversions (internal)
│ └── fm_msg.h # Message layer (internal)
├── test/ # Complete API usage examples (highly recommended)
├── app/ # Host applications and integration examples
│ ├── reader/ # Receive and parse over serial (optional Foxglove viz)
│ ├── writer/ # Encode and send to the device over serial
│ ├── ros1_converter/ # ROS1 topic conversion node
│ └── ros2_converter/ # ROS2 topic conversion node
├── msg/ # ROS message definitions
├── launch/ # ROS launch files
├── rviz/ # rviz display configs (one each for ROS1/ROS2)
├── package.xml # ROS package manifest
└── CMakeLists.txt
The pure C driver does just two things: encode the messages you want to send to the device into data frames, and parse the byte stream received from the device into messages. The actual I/O is left to you.
- Source files:
src/fm_crc.c,src/fm_frame.c,src/fm_driver.c - Header directory:
include/
In your application code you only need #include "fm_driver_for_user.h". The driver targets C11, performs no dynamic memory allocation, and has no third-party dependencies, making it suitable for resource-constrained MCUs.
A pure C project does not need
app/. For examples of wiring the driver APIs into real I/O, seeapp/reader/(parsing) andapp/writer/(encoding). The examples use C++ for serial I/O and logging, but call exactly the same driver APIs as a pure C project.
The APIs are declared in include/fm_driver_for_user.h, and the message IDs and structs in include/fm_driver_data.h (the former includes the latter, so your code only needs to include the former). Communication has two directions, "device → user" and "user → device":
- Encoding:
fm_prepare_msg_to_dev()packs one message (aFMData*struct) into a frame and returns the frame length. You then send the frame to the device over the serial port.- To pack multiple messages into a single frame, use the step-by-step API:
fm_prepare_msg_to_dev_begin()→fm_prepare_msg_to_dev_try_append()(callable multiple times) →fm_prepare_msg_to_dev_end().
- To pack multiple messages into a single frame, use the step-by-step API:
- Parsing: register callbacks with
FMParserFromDev+fm_parser_from_dev_init(). Feed every chunk of received serial data tofm_parser_from_dev_handle_data(); it automatically deframes and parses.on_frame_beginis invoked once at the start of each frame (providing the frame header info: role/uid/frame count),on_frame_msgonce per parsed message inside the frame (cast to the matchingFMData*struct based onmsg_id), andon_frame_endonce after the whole frame is processed; passNULLfor callbacks you don't need.
The mapping between message IDs (
FM_MSG_*) and their structs (FMData*), plus the meaning and unit of each field, is documented in include/fm_driver_data.h.If you are developing device-side firmware (encoding messages to send to the user), see include/fm_driver_for_dev.h.
#include "fm_driver_for_user.h"
#include <stdio.h>
// —— Receiving: invoked once per parsed message reported by the device ——
// FM_WIRED: the message comes from the wired (directly connected) device;
// FM_WIRELESS: it comes from the device on the wireless side
static void on_frame_msg_from_dev(fm_connect_type_e connect_type,
fm_msg_id_t msg_id, const void *msg_payload,
int msg_payload_size) {
switch (msg_id) {
case FM_MSG_SPHERICAL_RESULT: { // spherical positioning result
const FMDataSphericalResult *r =
(const FMDataSphericalResult *)msg_payload;
printf("dis=%.2f azimuth=%.2f elevation=%.2f\n", r->dis, r->azimuth,
r->elevation);
break;
}
default:
break;
}
}
static FMParserFromDev g_parser;
void app_init(void) {
// Register on_frame_begin/on_frame_end if you need the frame header info
// (role/uid/frame count)
fm_parser_from_dev_init(&g_parser, NULL, on_frame_msg_from_dev, NULL);
}
// Call this whenever the serial ISR/poll receives data
void app_on_serial_rx(const uint8_t *data, int size) {
fm_parser_from_dev_handle_data(&g_parser, data, size);
}
// —— Sending: issue a command to the device (e.g. make it vibrate/blink for 5s) ——
void app_send_find(void) {
FMDataFind find = {0};
find.duration = 5;
uint8_t frame[FM_FRAME_SIZE_MAX];
static fm_frame_cnt_t cnt = 0; // frame counter, +1 per frame
int n = fm_prepare_msg_to_dev(FM_WIRED, cnt++, FM_MSG_FIND, &find, frame,
sizeof(frame));
// user_serial_write is a serial send function you implement yourself
user_serial_write(frame, n);
}test/test_fm_driver.cpp covers encode/parse round-trip cases for every message in both directions. It is the most complete and authoritative API usage reference — refer to it directly during integration.
ROS1 and ROS2 share the same code. The node name and executable name are both fm_driver. The build picks the ROS version automatically from the ROS_VERSION environment variable, so no manual configuration is needed.
Prerequisite: ROS installed and
source /opt/ros/<distro>/setup.bashdone.
Clone this repository into your workspace's src/ directory (as fm_driver), then:
| ROS1 | ROS2 | |
|---|---|---|
| Build | catkin_make |
colcon build |
| Environment | source devel/setup.bash |
source install/setup.bash |
Connect the wired AOA device over serial, confirm the port, then launch:
roslaunch fm_driver msg.launch port:=/dev/ttyUSB0 # ROS1
ros2 launch fm_driver msg.py port:=/dev/ttyACM0 # ROS2Arguments (common to both):
port: serial device pathbaudrate: baud rate, default921600frame_id: frame the positioning result is expressed in, written toheader.frame_id, defaultfm_anchor_link
On ROS2 you can also run the node directly without launch:
ros2 run fm_driver fm_driver --ros-args -p port:=/dev/ttyACM0If you get a serial "Permission denied" error at startup, see Serial Port Permissions (Linux) below.
The rviz launch files start the driver, a static TF and rviz together, so the
positioning result (position plus uncertainty ellipsoid) is visible out of the box:
roslaunch fm_driver rviz.launch port:=/dev/ttyUSB0 # ROS1
ros2 launch fm_driver rviz.py port:=/dev/ttyACM0 # ROS2In addition to the arguments above:
parent_frame: parent frame of the anchor frame, defaultmap- Anchor pose within
parent_frame: on ROS1 useanchor_pose:="x y z yaw pitch roll"(all0by default); on ROS2 setx/y/z/yaw/pitch/rollindividually static_tf: whether to publish theparent_frame→frame_idstatic TF, defaulttrue. Set tofalseif that TF is already published elsewhere (e.g. by your robot URDF) to avoid conflicts
The positioning result is published in the anchor's own frame
fm_anchor_link. Where the anchor is mounted belongs to your URDF/calibration, so the driver itself publishes no TF. The static TF above merely gives rviz a usable TF tree for standalone testing.
rostopic echo /fm_driver/result # ROS1
ros2 topic echo /fm_driver/result # ROS2All topics are namespaced under the node name, i.e. /fm_driver/<topic>. Message field definitions are in the msg/ directory.
Device → User (subscribe to receive data)
| Topic | Message Type | Description |
|---|---|---|
~/result |
Result |
Positioning result (position/velocity/noise) |
~/result_pose |
geometry_msgs/PoseWithCovarianceStamped |
The same result as a standard message, for direct display in rviz |
~/prev_result |
PrevResult |
Previous positioning result (TAG) |
~/spherical_result |
SphericalResult |
Spherical positioning result (distance/azimuth/elevation) |
~/prev_spherical_result |
PrevSphericalResult |
Previous spherical result (TAG) |
~/dis |
Dis |
Ranging result |
~/heartbeat |
Heartbeat |
Device heartbeat/status info |
~/param |
Param |
Parameter read response/current parameters |
~/echo_from_device |
Echo |
Device echo |
~/user_data_from_device |
DataUserToUser |
User-defined data forwarded by the device |
User → Device (publish to send commands)
| Topic | Message Type | Description |
|---|---|---|
~/find |
Find |
Make the device vibrate/blink to locate it |
~/restart |
Restart |
Restart the device |
~/param_read |
std_msgs/Empty |
Read device parameters |
~/param_write |
Param |
Write device parameters |
~/begin_pair |
BeginPair |
Enter pairing mode |
~/cancel_pair |
CancelPair |
Cancel pairing |
~/echo_to_device |
Echo |
Echo test |
~/user_data_to_device |
DataUserToUser |
Send user-defined data |
On top of its existing log output, app/reader/ can host a Foxglove WebSocket
server. It does not depend on ROS, which makes it handy for inspecting data on
machines without a ROS installation. It is on by default in non-ROS builds:
cmake -B build && cmake --build build
./build/app/reader/reader --port /dev/ttyUSB0 # baudrate defaults to 921600, Foxglove port to 8765
./build/app/reader/reader --port /dev/ttyUSB0 --baudrate 921600 --foxglove_port 8765Then in Foxglove choose Open connection → Foxglove WebSocket and enter
ws://<device-ip>:8765. Log printing is unchanged; both run side by side.
| Topic | Type | Purpose |
|---|---|---|
/result_pose |
foxglove.PoseInFrame |
Position, shown in the 3D panel |
/result_scene |
foxglove.SceneUpdate |
Uncertainty sphere (2σ diameter) + velocity vector |
/tf |
foxglove.FrameTransform |
world → fm_anchor_link, the 3D panel's frame |
/result |
JSON | Position/velocity/noise, for the Plot panel |
/spherical_result |
JSON | Distance/azimuth/elevation, for the Plot panel |
/dis |
JSON | Ranging result, for the Plot panel |
Pass --record to record every topic in the table above into an MCAP file, which
you can drop straight into Foxglove for replay:
./build/app/reader/reader --port /dev/ttyUSB0 --recordThe file is written to logs/fm_<datetime>.mcap, next to the log file (e.g.
logs/fm_20260713_182432_123.mcap; it is precise to the millisecond, so repeated
runs never overwrite each other). Recording and the WebSocket server are
independent and can be used together.
Recording relies on the Foxglove SDK, so it requires
FM_BUILD_FOXGLOVE=ON(on by default in non-ROS builds). Exit withCtrl+C: the file is indexed and closed on shutdown, whereas killing the process (kill -9) leaves the MCAP without an index and it will not open.
Accessing a serial port for the first time often fails like this:
could not open port /dev/ttyUSB0: [Errno 13] Permission denied: '/dev/ttyUSB0'
Serial devices belong to the dialout group (uucp on some distros) and regular
users are not in it by default. Confirm the group with ls -l /dev/ttyUSB0, add
yourself to it, then log out and back in for it to take effect:
sudo usermod -a -G dialout $USER # -a is required, or you drop out of your other groupsReference: Fix Serial Port “Permission Denied” Errors on Linux
| Option | Default | Description |
|---|---|---|
FM_BUILD_ROS1 |
auto-detected from ROS_VERSION |
Build the ROS1 driver package |
FM_BUILD_ROS2 |
auto-detected from ROS_VERSION |
Build the ROS2 driver package |
FM_BUILD_READER |
ON for non-ROS / OFF for ROS builds | Build the serial receive/parse example |
FM_BUILD_WRITER |
ON for non-ROS / OFF for ROS builds | Build the encode/serial send example |
FM_BUILD_FOXGLOVE |
ON for non-ROS / OFF for ROS builds | Enable Foxglove visualization in the reader (fetches the prebuilt SDK) |
FM_BUILD_TEST |
ON for non-ROS / OFF for ROS builds | Build the pure C driver unit tests (fetches Catch2 automatically) |
If neither ROS option is set explicitly, the build picks one based on the ROS_VERSION environment variable, so catkin_make / colcon build in a ROS workspace just works.
Once ROS1 or ROS2 is enabled, the last four options all default to OFF: a ROS build only produces the fm_driver node, and it neither builds the host-side examples and unit tests nor fetches Catch2 / the Foxglove SDK. Turn any of them back on explicitly if you need it, e.g. to build and run the unit tests inside a ROS workspace:
catkin_make -DFM_BUILD_TEST=ON # ROS1
colcon build --cmake-args -DFM_BUILD_TEST=ON # ROS2