NUClear

Creating a NUClear module.
Ysobel Sims GitHub avatar
Updated 18 Oct 2023

Generating a Module

The first step on your NUClear journey is generating a module. This will give you a nice playground to try out NUClear functionality. Before you can generate a module, you will need to set up the codebase.

Setting up the Codebase

Setting up the codebase is vital! Most new members will have done this during the recruitment tasks, but if you did not do this or you have lost your setup head over to the Getting Started page.

Make sure you have

  • Git (command line, GitHub Desktop or GitKraken)
  • Docker
  • A text editor
  • Cloned the code
  • Run ./b target generic
  • Run ./b configure

If the last two commands ran successfully, you are ready to go!

The Folder Structure

Before getting into it, let's look at what we are working with.

If you open the NUbots repository in your text editor, you will see a whole lot of files and folders on the first level of the repository. Most of these you can ignore for now. Here are some of them which will be touched on in this tutorial.

  • module: Contains all the NUClear modules, which are the main pieces of code that run on the robots. This is where our new module will live, and where you will likely spend most of your time programming if you're working on robot functionality.
  • roles: Contains all the role files, which define a complete program. The files contain a list of modules that will be in the compiled program.
  • shared/message: You'll need to look inside the shared folder for this one. The message folder contains all the Protobuf messages that may be used in the system. NUClear is a message passing architecture, and these Protobuf files define those messages!

Other folders that are important but won't be visited in this tutorial are the following.

  • nusight2: Contains all the code for NUsight, our web-based visual debugging tool.
  • shared/utility: This folder contains many C++ files with functions and classes that are used in NUClear modules.
  • shared/tests: This folder contains automated tests, which use the Catch framework.

Generate the Module

The generate tool will create the boilerplate code for a module. Here's the command

./b module generate TestModule

Run it if you haven't already done so. If you look in the module folder, you will see a new folder called TestModule.

This module wasn't created in a folder, but if you are making a new module in a real scenario you should identify the folder your module best fits in and put it in there.

Lets see what we have in this folder.

  • data/config: Contains a TestModule.yaml file, which holds configuration values. Rather than hardcoding values in your implementation, put the values in this file so they can be easily seen and updated. This file is generated with one value, log_level. We will look into how this works later.
  • src: This folder contains the .hpp and .cpp files for the module. If you look in the .hpp, you'll see a TestModule class is declared that extends NUClear::Reactor. The .cpp file implements the constructor with one lambda statement that reads configuration values.
  • tests: This contains a file that can be used to test the reactors in the module. We will not look at this, but if you want an example go to module/extension/FileWatcher/tests/.
  • CMakeLists.txt: This allows CMake to configure the module - we won't change this.
  • README.md: Documentation for the module. We will be filling this out - it is very important to document your code.

Creating a Program

Ok, so we have a module. It doesn't do much yet, but it should compile. But, it will only compile if it is included in a role file. Let's make a role file.

Go to the role folder in the repository. Create a new file called testprogram.role. Put the following in the file

nuclear_role(
# FileWatcher, Signal Catcher and ConsoleLogHandler Must Go First
extension::FileWatcher
support::SignalCatcher
support::logging::ConsoleLogHandler
TestModule
)

Most of this is copied from other role files. The first three modules are necessary for many programs.

  • FileWatcher watches the configuration files (the .yaml files) for any changes.
  • SignalCatcher allows the user to CTRL + C to terminate the program cleanly.
  • ConsoleLogHandler allows log statements to be output.

The last module added is our new module, TestModule.

This should compile. First we'll need to turn the role we just made ON. Run

./b configure
./b configure -i

You'll see an interface appear. There will be a long list of entries starting with ROLE set to either ON or OFF. Arrow key down to those lines and press Enter to toggle the role on or off. Turn every role off except for ROLE_testprogram.

Now run

./b build

This should have compiled. Let's run it.

./b run testprogram

You should see the NUbots logo, and the name of the role, and statements telling you what modules have been installed. If you see that, great! But, this isn't very exciting. It doesn't really do anything yet! Let's implement something in our module.

Hit Ctrl + C to stop the program.

Configuration

Let's take a look at the configuration file in our module. Open module/TestModule/data/config/TestModule.yaml.

This file will store any of values for the program that might change. It is bad practice to hardcode values, and configuration files provide a way of avoiding that. Another thing about configuration files is that they can be changed while a program is running, and that change will be reflected in the program.

Open module/TestModule/src/TestModule.cpp. There is one on statement here, the Configuration on statement. The code inside this function will run on startup and whenever the configuration file TestModule.yaml is changed.

Inside this statement, after the log_level is set, add the following code

log<NUClear::TRACE>("This is a TRACE log.");
log<NUClear::DEBUG>("This is a DEBUG log.");
log<NUClear::INFO>("This is a INFO log.");
log<NUClear::WARN>("This is a WARN log.");
log<NUClear::ERROR>("This is a ERROR log.");
log<NUClear::FATAL>("This is a FATAL log.");

log will print to the terminal. The log level set from the configuration file determines what level logs are printed. The log level is currently on INFO. Let's compile and run the program again.

./b build
./b run testprogram

You should see all of the logs print except for TRACE and DEBUG. This is because all logs with level INFO and higher will run. Lets change the configuration file without stopping the program! Open a new terminal and run ./b edit config/TestModule.yaml. This will open the TestModule configuration file in nano. Change INFO to TRACE. Save with CTRL + O and then Enter. If you look at the program, you will see the logs print out again, but with TRACE and DEBUG included. Go back and change the config file to WARN. You will now see the logs print again, but with only WARN, ERROR and FATAL.

The log level system allows you to keep statements for debugging or tuning without the logs spamming the terminal when someone is running the module for other purposes.

Remove all of the log statements. We will be making a 'ping-pong' program that sends an incrementing count between reactions. The increment size will be a configuration value. Let's add that configuration value now.

In TestModule.yaml (in your text editor, not with ./b edit since this is not persistent), add in the following lines

# Each time the Ping Pong messages are sent, a count is incremented and the increment is of the following size
increment: 2

Next we need to read it in the module. Go to TestModule.hpp. We will make a variable to store this in. Inside the config struct, add in

/// @brief How much to increment the count by each time a new Ping or Pong message is emitted
int increment = 0;

Always initialise your variables and add documentation directives. The @brief is a documentation directive that indicates that the comment is a brief description of the following variable. These are used to generate documentation.

Save and go to TestModule.cpp. Add in the following lines to read the configuration value and print a message to let the user know the increment size.

config.increment = cfg["increment"].as<int>();
log<NUClear::INFO>("Increment is of size", config.increment);

Save, recompile and check that the program prints the message.

./b build
./b run testprogram

Make sure you see your INFO log and then lets move onto making the messages that we need. If you can't see the log in the terminal, make sure that log_level in TestModule.yaml is set to INFO.

Protobuf Messages

The idea of the program is that one reactor will send a Ping message when it receives a Pong message, and the other reactor will send a Pong message when it receives a Ping message.

We will need to create these messages. In shared/message create a new file PingPong.proto. Put the follow code into the file.

syntax = "proto3";
package message;
message Ping {
uint32 count = 1;
}
message Pong {
uint32 count = 1;
}

There is an unsigned 32-bit integer in each of these messages. This is the count that will be incremented each time a new Ping or Pong message is sent. The number it is assigned, 1, is not the value but its position in the Protobuf message. Save this file and head back over to TestModule.cpp.

Up at the top of the file, include the protobuf message we just created. Note that these messages are transcompile into C++ code, so include the generated header.

#include "message/PingPong.hpp"

Make sure it compiles and still runs successfully. Because we added files, we will need to configure again.

./b configure
./b build
./b run testprogram

If this works, you can move onto the next section where we make our reactions.

Reactions

Reactions are chunks of code that will run given certain conditions are true. We will make two reactions, one that runs tasks whenever it gets a Pong message, and the other when it gets a Ping message.

Head back to TestModule.cpp. Underneath the Configuration reactor, add in the following reaction

on<Trigger<Ping>>().then([this](const Ping& ping) {
// Some code!
});

Trigger means that it will run when it a Ping message is available. We get access to this message via the ping parameter. Let's add in the other reaction.

on<Trigger<Pong>>().then([this](const Pong& pong) {
// Some code!
});

Very similar, but it gets the Pong message instead.

Next, we want to get the count from the Ping or Pong message, increment it, and send a new message with the incremented count. Here's the code for the first reaction.

// Print the Ping message!
log<NUClear::INFO>("Ping count", ping.count);
// Make a Pong message to send
auto pong = std::make_unique<Pong>();
pong->count = ping.count + config.increment;
// Send the message
emit(pong);

And for the second reaction

// Print the Pong message!
log<NUClear::INFO>("Pong count", pong.count);
// Make a Ping message to send
auto ping = std::make_unique<Ping>();
ping->count = pong.count + config.increment;
// Send the message
emit(ping);

Have a look through the comments and make sure you understand the code. auto is a keyword that automatically determines the type of the variable.

Is everything ready now to see the messages ping-ponging between each other, printing out increasing count values? Compile the program and run it, and see what happens.

./b build
./b run testprogram

Well... Nothing changed. If you're not sure why, have a think about it before moving onto the next section.

Starting the Chain

The problem is, that there is no initial Ping or Pong message. No one ever sends the first message - the first reactor is forever waiting for a Ping message, and the second reactor is forever waiting for a Pong message!

Ok, so how do we start the reaction tasks? There are a few ways it could be done, but we will use the Startup NUClear domain specific language (DSL) word. This is another reaction that will run only when the program starts up.

Here is the code for the reaction.

on<Startup>().then([this] {
// Make an initial Ping message to send
auto ping = std::make_unique<Ping>();
ping->count = 0;
// Send the message
emit(ping);
});

Let's compile and run it.

./b build
./b run testprogram

That was a bit too fast! Stop the program if you haven't already. Let's review our reactions. There's nothing stopping them from running as fast as they can, they will keep sending messages to each other as fast as possible!

Here's where the Every DSL word comes in. Let's replace the Trigger<Ping> with Every<2, std::chrono::seconds>, With<Ping>. This will prevent the reaction from running tasks faster than once every two seconds. Compile and run again.

./b build
./b run testprogram

You should see the Ping and Pong logs running at a reasonable speed.

Now that you understand the module, you can fill in the README.md file. This is a short description of the module. The README is generated with headings to guide the content. Here are some hints for each section.

  • Description: Write in your own description of what the module does.
  • Usage: Since it is not dependent on other modules, the user only needs to include the module in their role to use it.
  • Consumes: The the Ping and Pong messages are consumed by the reactors.
  • Emits: The Ping and Pong messages are emitted by the reactors.
  • Dependencies: This module doesn't have any dependencies of note.

This is the end of this tutorial. If you want to learn more, play around with some of the other NUClear DSL words and see how they work.

If you had any problems setting up the program, you can find all the code files here.

NUbots acknowledges the traditional custodians of the lands within our footprint areas: Awabakal, Darkinjung, Biripai, Worimi, Wonnarua, and Eora Nations. We acknowledge that our laboratory is situated on unceded Pambalong land. We pay respect to the wisdom of our Elders past and present.
Copyright © 2024 NUbots - CC-BY-4.0
Deploys by Netlify