The Asset Tracker Template application leverages Zephyr features to create a modular, event-driven system. Key to the template architecture are zbus for inter-module communication and the State Machine Framework (SMF) for managing module behavior.
This document provides an overview of the architecture and explains how the different modules interact with each other, with a focus on the zbus messaging bus and the State Machine Framework.
The Asset Tracker Template is built around a modular architecture where each module is responsible for a specific functionality. The template consists of the following modules:
- Main module: The central coordinator that implements the business logic and controls the overall application flow
- Network module: Manages LTE connectivity and tracks network status
- Cloud module: Handles communication with nRF Cloud using CoAP
- Location module: Provides location services using GNSS, Wi-Fi and cellular positioning
- LED module: Controls RGB LED for visual feedback
- Button module: Handles button input for user interaction
- FOTA module: Manages firmware over-the-air updates
- Environmental module: Collects environmental sensor data (temperature, humidity, pressure)
- Power module: Monitors battery status and provides power management
The diagram below shows the system architecture and how the modules interact with each other. The modules are connected through a zbus messaging bus, which allows them to communicate with each other without being directly aware of each other. This decouples the modules and allows for easier maintenance and extensibility.
Here's a simplified flow of a typical operation:
- The Main module schedules periodic triggers or responds to button presses reported on the
BUTTON_CHAN
channel - When triggered either by timeout or button press, it requests location data from the Location module on the
LOCATION_CHAN
channel - After location search is completed and reported on the
LOCATION_CHAN
, the main module requests sensor data from the Environmental module on theENVIRONMENTAL_CHAN
channel - Throughout the operation, the main module controls the LED module over the
LED_CHAN
channel to provide visual feedback about the system state
Each module follows a similar pattern:
- State machine: Most modules implement a state machine using SMF to manage their internal state and behavior
- Message channels: Each module defines its own zbus channels and message types for communication
- Thread: Modules that need to perform blocking operations have their own thread
- Initialization: Modules are initialized at system startup, either through SYS_INIT() or in its dedicated thread
As a rule of thumb, modules in the Asset Tracker Template should, when possible, not be aware of each other, and should not have any dependencies between them. This is not always possible, but it is a good practice to strive for. Some modules will have to be aware of each other, such as the Main module, which is the central module that controls the system. The Main module will have to be aware of other modules in the system to implement the business logic of the application.
The zbus documentation provides a good introduction to the zbus library, and this section will only cover the parts that are relevant for the Asset Tracker Template:
- Channels and messages
- Channel observers: Message subscribers and listeners
- Message sending and receiving
In the Asset Tracker Template, each module defines channels and message types in their own header file. For example, in modules/network/network.h
, the Network module defines the NETWORK_CHAN
channel and the struct network_msg
message type.
Channels are defined using ZBUS_CHAN_DEFINE
, specifying the channel name, message type and more. For example:
ZBUS_CHAN_DEFINE(NETWORK_CHAN, /* Channel name */
struct network_msg, /* Message type */
NULL, /* Optional validator function */
NULL, /* Optional pointer to user data */
ZBUS_OBSERVERS_EMPTY, /* Initial observers */
ZBUS_MSG_INIT(.type = NETWORK_DISCONNECTED) /* Message initialization */
);
In the above example, initial observers are set to ZBUS_OBSERVERS_EMPTY
to indicate that no observers are initially listening on the channel. However, observers can still be added at compile time using ZBUS_CHAN_ADD_OBS
. It is also possible to add observers at runtime using zbus_chan_add_obs
. The reason that we need to set the initial observers to ZBUS_OBSERVERS_EMPTY
is that the Network module is not aware of any other modules in the system, and doing it this way avoids coupling between modules.
Message data types may be any valid C type, and their content is specific to the needs of the module. For example from the Network module header file:
struct network_msg {
enum network_msg_type type;
union {
enum lte_lc_system_mode system_mode;
/* Other message-specific fields */
};
};
There are a couple of things to note in the above example on how message types are defined in the Asset Tracker Template:
-
The message type is an enumerator that is specific to the Network module. The message type is typically used in a switch-case statement in the subscriber to determine what action to take and what, if any, other fields in the message to use.
-
When there are multiple message types in a message, it is a good practice to use a union to save memory. This is because the message will be allocated on the stack when it is sent, and it is good to keep the message size as small as possible. We have chosen to use anonymous unions in the Asset Tracker Template to avoid having to write the union name when accessing the union members.
A zbus observer is an entity that can receive messages on one or more zbus channel. There are multiple types of observers, including message subscribers and listeners, which are the only ones used in the Asset Tracker template. These observer types offer message delivery guarantees and are used in different scenarios.
Used by modules that have their own thread and that perform actions that may block in response to messages.
For example, the Network module subscribes to its own NETWORK_CHAN
channel to receive messages about network events. The module may react to a message by sending some AT command to the modem, which may block until some signalling with the network is done and a response is received. This is why the module has its own thread and needs to be a message subscriber.
A message subscriber will queue up messages that are received while the module is busy processing another message. The module can then process the messages in the order they were received. An incoming message can never interrupt the processing of another message.
A message subscriber is defined using ZBUS_MSG_SUBSCRIBER_DEFINE
, and the subscriber is added to a channel using ZBUS_CHAN_ADD_OBS
. For example, in the Network module it subscribes to its own channel like this:
ZBUS_MSG_SUBSCRIBER_DEFINE(network_subscriber);
ZBUS_CHAN_ADD_OBS(NETWORK_CHAN, network_subscriber, 0);
Used by modules that do not have their own thread and that does not block when processing messages. A listener will receive a message synchronously in the sender's context. For example, the LED module listens for messages on the LED_CHAN
channel. When it receives an LED_RGB_SET
message from the main module, it will immediately set the RGB LED color without blocking. This happens in the main module's context. The LED module does not have its own thread and does not block when processing messages, so it can be a listener.
A listener is defined using ZBUS_LISTENER_DEFINE
, and the listener is added to a channel using ZBUS_CHAN_ADD_LISTENER
. For example, in the LED module it listens for messages on its own channel like this:
ZBUS_LISTENER_DEFINE(led, led_callback);
ZBUS_CHAN_ADD_LISTENER(LED_CHAN, led, 0);
Messages are sent on a channel using zbus_chan_pub()
. For example, to send a message to the LED_CHAN
channel:
struct led_msg msg = {
.type = LED_RGB_SET,
.red = 255,
.green = 0,
.blue = 0,
.duration_on_msec = 1000,
.duration_off_msec = 1000,
.repetitions = 10,
};
err = zbus_chan_pub(LED_CHAN, &msg);
The LED module will receive the message and call the led_callback
function with the message data, as described in Listeners.
If the LED module observer were a message subscriber, the message would be queued up until the module is ready to process it.
The State Machine Framework (SMF) is a Zephyr library that provides a way to implement hierarchical state machines. The Asset Tracker Template uses SMF extensively to manage module behavior and state transitions. Several key modules including Network, Cloud, FOTA, and the Main module implement state machines using SMF.
The documentation on SMF provides a good introduction, and this section will only cover the parts that are relevant for the Asset Tracker Template.
The state machine implementation follows a run-to-completion model where:
- Message processing and state machine execution, including transitions, complete fully before processing new messages
- Entry and exit functions are called in the correct order when transitioning states
- Parent state transitions are handled automatically when transitioning between child states
This ensures predictable behavior and proper state cleanup during transitions, as there is no mechanism for interrupting or changing the state machine execution from the outside.
States are defined using the SMF_CREATE_STATE
macro, which allows specifying:
- Entry function: Called when entering the state
- Run function: Called while in the state
- Exit function: Called when leaving the state
- Parent state: For hierarchical state machines
- Initial transition: A state may transition to a sub-state upon entry.
Example from the Cloud module:
[STATE_CONNECTED] =
SMF_CREATE_STATE(state_connected_entry, /* Entry function */
NULL, /* Run function */
state_connected_exit, /* Exit function */
&states[STATE_RUNNING], /* Parent state */
&states[STATE_CONNECTED_READY]), /* Initial transition */
The framework supports parent-child state relationships, allowing common behavior to be implemented in parent states. For example, in the Network module:
STATE_RUNNING
is the top-level stateSTATE_DISCONNECTED
andSTATE_CONNECTED
are child states ofSTATE_RUNNING
STATE_DISCONNECTED_IDLE
is a child state ofSTATE_DISCONNECTED
This hierarchy allows for shared behavior and clean state organization.
Here is the full state machine of the network module, both graphically and SMF implementation:
static const struct smf_state states[] = {
[STATE_RUNNING] =
SMF_CREATE_STATE(state_running_entry, state_running_run, NULL,
NULL, /* No parent state */
&states[STATE_DISCONNECTED]),
[STATE_DISCONNECTED] =
SMF_CREATE_STATE(state_disconnected_entry, state_disconnected_run, NULL,
&states[STATE_RUNNING],
&states[STATE_DISCONNECTED_SEARCHING]),
[STATE_DISCONNECTED_IDLE] =
SMF_CREATE_STATE(NULL, state_disconnected_idle_run, NULL,
&states[STATE_DISCONNECTED],
NULL), /* No initial transition */
[STATE_DISCONNECTED_SEARCHING] =
SMF_CREATE_STATE(state_disconnected_searching_entry,
state_disconnected_searching_run, NULL,
&states[STATE_DISCONNECTED],
NULL), /* No initial transition */
[STATE_CONNECTED] =
SMF_CREATE_STATE(state_connected_entry, state_connected_run, NULL,
&states[STATE_RUNNING],
NULL), /* No initial transition */
[STATE_DISCONNECTING] =
SMF_CREATE_STATE(state_disconnecting_entry, state_disconnecting_run, NULL,
&states[STATE_RUNNING],
NULL), /* No initial transition */
};
In the image above, the black dots and arrow indicate initial transitions.
In this case, the initial state is set to STATE_RUNNING
. In the state machine definition, initial transitions are configured, such that the state machine will end up in STATE_DISCONNECTED_SEARCHING
when first initialized.
From there, transitions will follow the arrows according to the messages received and the state machine logic.
It is important to note that in a hierarchical state machine, the run function of the current state is executed first, and then the run function of the parent state is executed, unless a state transition happens, or the child state marks the message as handled using smf_state_handled()
.
Each module that uses SMF maintains a context structure, which is usually embedded within a state structure for the module that contains other relevant data for the module's operation. Example from the cloud module:
struct cloud_state {
/* This must be first */
struct smf_ctx ctx;
/* Last channel type that a message was received on */
const struct zbus_channel *chan;
/* Last received message */
uint8_t msg_buf[MAX_MSG_SIZE];
/* Network status */
enum network_msg_type nw_status;
/* Connection attempt counter. Reset when entering STATE_CONNECTING */
uint32_t connection_attempts;
/* Connection backoff time */
uint32_t backoff_time;
};
The SMF context struct member is used to track the current state and manage state transitions. It is passed to all SMF function calls.
State machines are initialized to an initial state using smf_set_initial()
:
smf_set_initial(SMF_CTX(&module_state), &states[STATE_RUNNING]);
This has to be done before the state machine is executed the first time.
The state machine is run using smf_run_state()
, which:
- Executes the run function of the current state if it is defined
- Executes the run function of parent states unless:
- A state transition happens
- A child state marks the message as handled using
smf_state_handled()
- Executes the exit function of the current and parent states when leaving a state
Transitions between states are handled using smf_set_state()
:
smf_set_state(SMF_CTX(state_object), &states[NEW_STATE]);
A transition to another state has to be the last thing happening in a state handler. This is to ensure correct order of execution of parent state handlers. SMF automatically handles the execution of exit and entry functions for all states along the path to the new state