Introduction
This site contains the labs for the class ECE_5SE03_TP: Embedded Systems and IoT at Télécom Paris (DIOC : développement intégré d'objets connectés). You may freely use this lab for your own use, but please get in touch if you intend to reuse it in another context.
You can work offline by downloading the current state of this lab: book.tar.xz.
ⓒ 2022–2024 Samuel Tardieu — All rights reserved
Zephyr and application setup
This lab will use the Zephyr real-time operating system. Zephyr is an open-source projet of the Linux Foundation.
You will have two options for working on this lab: you can use your own computer, or you can use Telecom Paris lab computers.
Using your own computer
If you wish to do this lab on your own computer, it must be running a Unix-like system such as Linux or macOS. You may be able to do the lab in Microsoft Windows but do not expect help from the teachers (you're welcome to provide a step-by-step guide that would be included here).
The Zephyr web site contains instructions to install Zephyr.
You will have perform the following steps:
- Install the required packages on your computer (such as cmake).
- Decide where you will install Zephyr (for example
$HOME/zephyrproject
). - Create a virtual Python 3 environment (for example in
$HOME/zephyrproject/venv
):
$ mkdir $HOME/zephyr
$ python3 -mvenv $HOME/zephyr/venv
- Activate this environment by sourcing the
activate
file:
$ source $HOME/zephyr/venv/bin/activate
- Install
west
, Zephyr toolbox utility:
$ pip install west
- Get Zephyr source code from git using
west
and update its dependent git repositories:
$ cd $HOME/zephyrproject
$ west init
$ west update
- Register Zephyr location for cmake:
$ west zephyr-export
- Install Zephyr Python requirements:
$ pip install -r zephyr/scripts/requirements.txt
- Install Zephyr SDK (see the instructions to install the SDK).
- Register Zephyr SDK location for cmake by running
setup.sh
found inside the SDK directory. - Install udev rules as per the instructions page.
Keep in mind the following points:
- Zephyr and its SDK might take up to 25GB on your computer. Please ensure that you have enough room available.
- Do not forget to activate the virtual Python environment in any terminal where you wish to use Zephyr.
- You need to set the
ZEPHYR_BASE
environment variable to thezephyr
directory created bywest init
(for example$HOME/zephyrproject/zephyr
).
Using Telecom Paris computer lab
It is possible to use Telecom Paris computer lab instead of your own computer. However, this will be much slower because many files will transit over the network.
Install Zephyr
You must set the environment variable ZEPHYR_BASE
to /comelec/softs/opt/zephyr-rtos/sources/zephyr
. Modify your shell init file so that this variable is always in your environment.
You must execute the /comelec/softs/opt/zephyr-rtos/conf/cmake-register-zephyr.sh
script once. It will update your cmake
configuration (in $HOME/.cmake/packages
) with Zephyr location information.
You must activate the Python virtual environment in every shell where you want to use west
, by executing source /comelec/softs/opt/zephyr-rtos/sources/west-venv/bin/activate
.
You're all set. This is a relatively recent development of Zephyr, and we might update it from time to time, but that should be a transparent operation for you.
☹️ If something is wrong the way the Telecom Paris computer lab environment is set up, please notify the staff through the course mailing-list so that it can be fixed promptly.
Zephyr application
Now that Zephyr has been installed, we will be able to write our first application. Rather than using a real board, we will start by using a PC emulator.
Anatomy of an application
Our Zephyr applications will be freestanding. It means that we will keep our application code outside Zephyr source tree in order to only keep the application in our git repository.
A freestanding Zephyr application will make use of several directories:
- The virtual Python environment in which we have installed
west
and the required Python libraries. - The Zephyr source code and its associated libraries. In order to use
west
, we will set theZEPHYR_BASE
environment variable to point at it. - Your application source code, which you will create in your git repository.
- A build directory: Zephyr applications are built out of tree. It means that the build process will never mix the source code with the build artifacts. If your build directory is named
build
, you can remove it at any time in order to free up some disk space.
Creating an application
Your first Zephyr application
Create a directory in your git working directory. Create the following two files.
In CMakeLists.txt
:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr)
project(my_zephyr_app)
target_sources(app PRIVATE src/main.c)
In src/main.c
:
#include <stdio.h>
int main() {
printf("Hello, world\n");
return 0;
}
You also need to create a configuration file prj.conf
. In our case, we do not want to configure anything, so it can be empty:
$ touch prj.conf
We will now build the application for our target. In this case, we will generate code for a PC using an Intel CPU in bare-board mode and emulate the PC using the QEMU emulator.
Provided that west
is in your $PATH
and that $ZEPHYR_BASE
is properly set if needed, you should be able to build your application for the board qemu_x86
:
$ west build -b qemu_x86
(if you do not use west
, you can use the equivalent cmake -B build -DBOARD=qemu_x86 -GNinja && ninja -C build zephyr/zephyr.elf
)
This builds zephyr/zephyr.elf
into the build
directory. You should ignore this directory in your .gitignore
as its content should never be added to the git repository.
You can now run the application. You do not need to give the board again as it is remembered.
$ west build -t run
-- west build: running target run
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build v3.2.0-rc2-37-gd9f327fb8434 ***
Hello, world
(without west
: ninja -C build run
)
Congratulations: you just ran your first Zephyr application. You can quit qemu with ctrl-a x
(control key + a
, then x
), or simply kill the qemu process with ctrl-c
.
Adding a shell
Zephyr comes with an interactive shell. We want to add it to our project.
Run west build -t menuconfig
(or ninja -C build menuconfig
) and look for the SHELL
option. You can find it under "Sub Systems and OS Services / Shell". Activate the shell by pressing the space key (the box will be ticked). Also, activate the UART
shell backend, so that the shell presents itself on the serial port.
You may want to disable the QEMU_ICOUNT
option found in "Board Options" as it won't work nice with the shell. You do not need to change any other option. Leave menuconfig
by pressing Q
and saving the file.
You can now run the application again and use the interactive shell:
$ west build -t run
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build v3.2.0-rc2-37-gd9f327fb8434 ***
Hello, world
uart:~$
You can now enter commands such as "help" at the prompt ("uart:~$ " by default). For the time being, only the "help" command is available, but we will add more commands.
Making the change persistent
The changes we made with menuconfig
are stored in build/zephyr/.config
using the Kconfig
format. As soon as we remove the build
directory, we will lose them.
Make the change persistent by putting some content into the prj.conf
file at the top-level of your application:
CONFIG_SHELL=y
CONFIG_SHELL_PROMPT_UART="(zephyr) "
We have also changed the prompt. Let's rebuild the application from scratch:
$ rm -rf build
$ west build -t run -b qemu_x86
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build v3.2.0-rc2-37-gd9f327fb8434 ***
Hello, world
(Zephyr)
Do not forget to add prj.conf
to git. Commit, then push.
Exploring the threads
Use the kernel thread list
shell command to examine the running threads. You should be able to identify the following ones:
-
idle
: the idle thread, which takes care of putting the system to sleep. Zephyr will run the threads with lower priority values first. The idle thread has the highest priority value, which means that it will run only when nothing else needs to run. -
shell_uart
: the shell we are executing commands into. The shell runs into its own thread. It uses a very low priority task (with a high numerical value) as to not disturb the system it is interacting with. -
sysworkq
: Zephyr default workqueue. A workqueue is a thread on which jobs are sent to be executed serially, for example from an interrupt service routine (ISR). Note the negative priority: the workqueue is a very important thread.
Finding main
However, we don't see main
in the list. Indeed, our main function exits as soon as it has printed.
Add an infinite loop at the end of main()
. Why can't you use the shell? Use a proper way of making main()
wait forever without consuming any resource. Zephyr documentation will be a very useful resource. Do not forget to include zephyr/kernel.h
to use kernel services.
Find the priority of the main
thread. Change it in the project configuration file and check that it has been properly set.
Switching board
Using ARM architecture
It is easy to switch to a new board in Zephyr. For example, instead of qemu_x86
which emulates a PC, we will emulate a Cortex-M3 based system using qemu_cortex_m3
.
Recompile and run your application for the qemu_cortex_m3
board and check that everything still works as expected. If you run the command file build/zephyr/zephyr.elf
, you should see that it is indeed an ARM application.
Using a real physical board
We will now use a IoT node board as we did in the SE203 class. This board has a number of sensors and effectors that can be useful in the future.
You can see the list of supported boards using west boards
. The board we are interested in is the disco_l475_iot1
. Compile your application for this board.
⚠️ If you use Telecom Paris computer lab, you must place the following directory at the beginning of your
PATH
:/comelec/softs/opt/Segger/JLink_current/
. You should update your shell configuration file to do so each time you log in.
Our boards are configured to use Segger JLink tools. We can flash the program and reset the board by using:
$ west flash --runner jlink --reset
(if you don't use west
, you can use ninja -C build flash
provided that you set the BOARD_FLASH_RUNNER
variable to jlink
at cmake time, see this page for more information)
You can now connect to the /dev/ttyACM0
(for Linux, other systems do have equivalent names) and interact with the shell:
$ tio /dev/ttyACM0
(Zephyr)
Note how Zephyr was able to use the serial port on your board without you telling it explicitly. In build/zephyr/zephyr.dts
you will have access to the application device tree. The device tree describes the hardware and interacts with the right device drivers.
Note the following fragments:
/ {
chosen {
zephyr,console = &usart1;
zephyr,shell-uart = &usart1;
};
soc {
usart1: serial@40013800 {
compatible = "st,stm32-usart", "st,stm32-uart";
reg = < 0x40013800 0x400 >;
clocks = < &rcc 0x60 0x4000 >;
resets = < &rctl 0x80e >;
interrupts = < 0x25 0x0 >;
status = "okay";
current-speed = < 0x1c200 >;
pinctrl-0 = < &usart1_tx_pb6 &usart1_rx_pb7 >;
pinctrl-names = "default";
};
};
};
You can notice that the console (printf()
) and the shell both opted to choose the device whose alias is usart1
(the serial port connected to the JLink compatible chip on the board, accessible under /dev/ttyACM0
or a similar name). Also, you can see how usart1
is defined and require the use of the st,stm32-usart
and st,stm32-uart
device drivers.
Using RTT for the shell instead of the UART
Since we use the Segger JLink tools, we can request the use of Segger RTT instead of a serial port for the shell. By adding the following line to prj.conf
you can indicate that the shell must use a RTT interface instead of a shell:
CONFIG_USE_SEGGER_RTT=y
CONFIG_SHELL_PROMPT_RTT="(Zephyr) "
CONFIG_SHELL_BACKEND_SERIAL=n
CONFIG_SHELL_BACKEND_RTT=y
You will get a warning about the now unused CONFIG_SHELL_PROMPT_UART
: this is due to the fact that the serial backend for the shell is no longer used.
Flash and run the application on the board. You should notice that only the "Hello, world!" arrives through the serial port when you reset the board. Launch JLink RTT logger after flashing the board:
$ JLinkRTTLogger -device STM32L475VG -RTTChannel 1 -if SWD -Speed 4000
and connect to port 19021
using nc
:
$ nc localhost 19021
You can now type command in the nc
window and interact with the shell.
Now revert your changes as we will keep the shell on the serial port for now.
Some Zephyr concepts
In this part, you will learn how to use some Zephyr concept, such as the device drivers, the device tree and kernel services.
Using the device tree
You can find the consolidated device tree for your application in build/zephyr/zephyr.dts
after the build phase. This device tree brings together your board-specific device tree, as well as the overlays defined specifically for your application or for additional components that you may declare to be using (such as an Arduino compatible shield).
Zephyr uses C (or C++) macros to access the device tree. The use of macros allows those to be resolved at compile time: a pre-processing phase compiles the device tree into a set of C headers whose content is manipulated through the macros.
An example: GPIOA
Here is a very simplified excerpt of the device tree for your application centered around gpioa
:
/ {
soc {
pinctrl: pin-controller@48000000 {
compatible = "st,stm32-pinctrl";
gpioa: gpio@48000000 {
compatible = "st,stm32-gpio";
gpio-controller;
#gpio-cells = < 0x2 >;
reg = < 0x48000000 0x400 >;
clocks = < &rcc 0x4c 0x1 >;
phandle = < 0xb >;
};
};
};
};
The device tree is made of nodes arranged in a tree fashion: a node may have children. Also, every node can have attributes.
In this example:
/
represents the root node which is the base of the tree.soc
is a child of/
, its path is/soc
.pin-controller@48000000
is a child of/soc
. Its path is/soc/pin-controller@48000000
. We also give this node a labelpinctrl
. From now on, we will be able to reference this node by its label instead of using its path. From other parts of the device tree itself, we may use&pinctrl
to reference the node.gpio@48000000
is a child of/soc/pin-controller@48000000
. Its path is/soc/pin-controller@48000000/gpio@48000000
. Its label isgpioa
. It can be referenced by label as&gpioa
from anywhere in the tree.
Let's assume that we want to use gpioa
in our application. We can reference the gpioa
device by its label and ask Zephyr to find it at compile time:
#include <zephyr/device.h>
// Define `GPIOA_NODE` as the node whose label is `gpioa` in the device tree
#define GPIOA_NODE DT_NODELABEL(gpioa)
// Get the struct device pointer to `gpioa` at compile time
static const struct device * const gpioa_dev = DEVICE_DT_GET(GPIOA_NODE);
Now that you have a reference to gpioa, you can setup the led at run-time and make it blink:
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/kernel.h>
// Define `GPIOA_NODE` as the node whose label is `gpioa` in the device tree
#define GPIOA_NODE DT_NODELABEL(gpioa)
// Get the struct device pointer to `gpioa` at compile time
static const struct device * const gpioa_dev = DEVICE_DT_GET(GPIOA_NODE);
int main() {
if (!device_is_ready(gpioa_dev)) {
return -ENODEV; // Device is not ready
}
// Configure the led as an output, initially inactive
gpio_pin_configure(gpioa_dev, 5, GPIO_OUTPUT_INACTIVE);
// Toggle the led every second
for (;;) {
gpio_pin_toggle(gpioa_dev, 5);
k_sleep(K_SECONDS(1));
}
return 0;
}
Note that Zephyr peripherals are initialized during different boot phases. If somehow you have misconfigured a peripheral, it might not be ready when you start, at which case you should abort. This is what is done at the beginning of main.
Ensure that you can make the led blink. Easy, no? Yes, but you can do much better.
Commit, push.
Ensure that you understand everything you've used before going on.
Let's do less manual work
As you might have noted, we had to retrieve the const struct device *
corresponding to gpioa
and configure and toggle pin 5. Of course, we do not want to hardcode the port and the pin number in our code.
Finding the led
If you look at $ZEPHYR_BASE/boards/st/disco_l475_iot1/disco_l475_iot1.dts
, you'll notice some interesting fragments, such as:
/ {
aliases {
led0 = &green_led_2;
};
leds {
compatible="gpio-leds";
green_led_1: {
gpios = < &gpiob 14 GPIO_ACTIVE_HIGH >;
};
};
};
The node /aliases
is a special one: it defines global aliases on nodes that can then be found with macro DT_ALIAS
, such as in DT_ALIAS(led0)
.
⚠️ Make sure that you use
DT_ALIAS
to refer to a global alias, andDT_NODELABEL
to refer to a node label, those are not interchangeable.
This would in turn reference the green_led_2
node label, which designated a node which contains an interesting gpios
property < &gpiob 14 GPIO_ACTIVE_HIGH >
with:
- a reference to a port (
&gpiob
) - a pin number (14)
- a
GPIO_ACTIVE_HIGH
flag
Note that the flags could have contained GPIO_ACTIVE_LOW
, which would have meant that the ACTIVE
state is the LOW
state, and the INACTIVE
state the HIGH
state. By using these flags when initializing the port in your code, you can then use ACTIVE
and INACTIVE
(rather than HIGH
and LOW
) and get the proper behaviour.
Our goal will be to retrieve and use this gpio structure.
Looking at the consolidated device tree
You will find the consolidated device tree in build/zephyr/zephyr.dts
: it contains a merge of $ZEPHYR_BASE/boards/arm/disco_l475_iot1/disco_l475_iot1.dts
and of your own device tree fragments. Right now we don't have any, but we will create some later.
Note that in this file, all numbers are printed in hexadecimal and flags have been replaced by their values. For example, you will find
/ {
aliases {
led0 = &green_led_2;
};
leds {
compatible="gpio-leds";
green_led_1: {
gpios = < &gpiob 0xe 0 >;
};
};
};
This is exactly the same thing as before: 14 == 0xe
, and GPIO_ACTIVE_HIGH
has a value of 0.
Retrieving the GPIO structure
Zephyr has a handy struct gpio_dt_spec
structure which contains three fields: a port device (such as gpioa), a pin number and the flags. Functions from the GPIO driver have an equivalent that takes such struct as a parameter. Also, some macros allow the easy parsing of this structure:
// Designate the `led0` alias from the device tree
#define LED_NODE DT_ALIAS(led0)
// Get the GPIO (port, pin and flags) corresponding to the
// `led0` node alias at compile time.
static const struct gpio_dt_spec led_gpio = GPIO_DT_SPEC_GET(LED_NODE, gpios);
int main() {
if (!device_is_ready(led_gpio.port)) {
return -ENODEV;
}
// Note that we use `INACTIVE`, not `LOW`. On some boards,
// the behaviour of the output may be reversed, but this
// is not our concern. This info belongs to the device tree.
gpio_pin_configure_dt(&led_gpio, GPIO_OUTPUT_INACTIVE);
for (;;) {
gpio_pin_toggle_dt(&led_gpio);
k_sleep(K_SECONDS(1));
}
return 0;
}
Do it, check that it works. Commit push.
Note how the only reference to the hardware is now the led0
name. But you can do better.
Change the led
What if you wanted to use another led as led0
?
Create a file named boards/disco_l475_iot1.overlay
at the top of your project. Now, you can redefine the alias:
/ {
aliases {
led0 = &green_led_1;
};
};
Since this is a new file, you must reconfigure the project. Remove the build
directory before rebuilding the project.
Check that the other green led blinks. Did you notice that you haven't changed one single line of C code?
Oh, enough played, you can remove this board specific files that you created and return the led to its original location.
The led device driver
In fact, Zephyr also has a led device driver. Each device can drive several leds. You can retrieve a const struct device * const
directly from the /leds
node and forget about even initializing the ports and so on.
Retrieve a pointer to the leds device and make the two leds at index 0 and 1 alternate every second (one is on, the other is off). Do not try to use led_blink()
: this function is optional and is not implemented for the GPIO led driver.
If you get an error, maybe you should see if you have enabled everything you need in prj.conf
. Is the led driver enabled?
Is it working now? Great!
However, note that we work with const struct device *
all the time: the device drivers are not strongly typed. This is your responsibility to only call the right functions on the right device.
Adding a new led
If you look at the board user manual you'll notice two other leds: a yellow one and a blue one. Both are driven at the same time: writing a high level to the corresponding port turns the yellow led on and the blue led off, writing a low level turns the yellow led off and the blue led on.
We want to add this new led as a child to the /leds
node. Create a file named boards/disco_l475_iot1.overlay
at the top of your project. In it, you will be able to add things to enrich the device tree:
/ {
leds {
/* What you will add here will be added to the /leds node */
};
};
Add a new led blue_led
in leds
which corresponds to the blue led. Use the board user manual to find on which port the led is connected. Is its active state GPIO_ACTIVE_HIGH
or GPIO_ACTIVE_LOW
?. What extra flag must you add so that the yellow led does not turn on when you turn off the blue led? Note that you can combine flags in the device tree by putting them between parentheses and separating them with a pipe symbol ((FLAG1 | FLAG2 | FLAG3)
).
You will have to remove the build
directory in order to have the build system consider the new file.
Once this is done, modify your code so that:
- turn on all leds (at index 0, 1, and 2)
- wait 1s
- turn all leds off
- wait 1s
- start over
Does the blue led light up at the same time as the green leds? Great!
Changing the meaning of the output
What if you consider that the led that should get turned on is the yellow led instead of the blue led? Change the flag in the overlay file that you have created to GPIO_ACTIVE_HIGH
. Run your program again.
Interesting. How can you drive the yellow led without driving the blue one? (you don't have to make it do so, just think about it)
If you have not done so, you need to read the introduction to device trees in Zephyr documentation.
Being more autonomous
You'll do more things on your own from now on.
Copy the example code from $ZEPHYR_BASE/samples/basic/button
and run it on your board. You'll see that:
- The main program does some setup (gpio and interrupt) then copies the state of the switch
sw0
to the ledled0
. - When the button is pressed, an interrupt is received and a message is printed.
Make sure you understand how the interrupts work, and what the ISR does.
Removing the loop
In order to let the system sleep, you will receive an interrupt both when the button is pressed and when it is released. In the interrupt service routine, you'll copy its state to the led.
Commit and push your code when it's done.
Adding a pause
We now want a different behaviour: when the button is pressed, the led will light up. It will go down one second later, unless the button is pressed again in the meantime.
Of course you do not want to use k_sleep()
in an interrupt callback routine. You will use a workqueue in which you'll submit delayable work. You can use the system workqueue, or you can start your own in a new thread.
Using sensors
We will now use various sensors that populate our board.
Using a sensor
In this part, we will use the VL53L0X time-of-flight sensor which can detect a distance using laser-ranging.
Using the board documentation (or the image of the sensor), locate the sensor on the board.
Referencing it as a sensor
Zephyr has a sensor
class of devices. In its simplest mode of operation, the user:
- requests that a measure be initiated;
- then retrieves the result of the measure in channels (for example
SENSOR_CHAN_HUMIDITY
for a humidity sensor).
All values are returned as a pair of integers, avoiding the need for floating point calculations in many cases. For some sensors, only the first value will be filled. The Zephyr documentation on sensors describes those interfaces.
In order to know what channels are available for the vl53l0x driver, locate its source files in Zephyr source tree (starting from $ZEPHYR_BASE
) and look for symbols starting with SENSOR_CHAN_
.
Locate and use the device
In your source code, you might want to use the DEVICE_DT_GET_ANY()
macro in order to locate any vl53l0x sensor on your board. In this case you know there is only one, so you don't have to look for its node label or its alias.
💡 In a device tree, you might encounter names such as
"st,vl53l0x"
: the first part designates the vendor (ST Microelectronics) and the second part designates the device (VL53L0X time of flight sensor). You cannot directory use those names in C macros, as the comma will be understood as a separator. The non-alphanumeric characters must be replaced by an underscore. For example,"st,vl53l0x"
will becomest_vl53l0x
as inDEVICE_DT_GET_ANY(st_vl53l0x)
.
In a loop, print repeatedly (for example every tenth of seconds) the distance between the sensor and the obstacle above it (for example your hand) using the sensor interface.
Note on displaying floating-point values
By default, Zephyr standard C library does not implement printing of floating-point values. If you want to use %f
or %g
formatters in functions of the printf
family, you can request for it to be included, by using:
CONFIG_CBPRINTF_FP_SUPPORT=y
However, it is best not to use floating point if you don't need to.
Make the led blink at a rate depending on the distance
Using several threads and communicating through a Message queue, make one of the green leds blink fast when your hand is near the board and much more slowly when it is far from the board.
Accelerometer
We will use the LSM6DSL accelerometer sensor located on our board using the I²C device driver.
The LSM6DSL documentation is accessible here.
⚠️ Important: in this part, you are supposed to send and receive I²C commands directly. Do not use the lsm6dsl driver included in Zephyr.
Check that you can address the LSM6DSL sensor
Check that you are able to read the WHO_AM_I
register from the LSM6DSL sensor and that it returns the expected value.
Did you hardcode the address of the device and the I²C bus to use? If you did, you can do better: the I2C_DT_SPEC_GET()
macro let you retrieve a struct i2c_dt_spec
structure which contains the bus and the device address corresponding to a node located on a I²C bus.
Locate the node corresponding to the LSM6DSL accelerometer in your device tree (you should now remember that it is available in build/zephyr/zephyr.dts
) and statically initialize a struct i2c_dt_spec
for it.
Since you used a static initialization, do not forget to check with device_is_ready()
that the I²C device is ready before using it for the first time.
You can use I²C functions ending in _dt
to avoid explicitely giving the bus and the device address.
Configure the LSM6DSL accelerometer
Configure the LSM6DSL sensor so that you it performs measures at 1.6Hz. Check that reading the accelerometer registers give you a credible value when you turn the board on one side or another.
Configure the interrupt line connected to the microcontroller so that you know when a new measure has been done. When this happens, trigger a read of the accelerometer axes and display the value.
Pitfalls and advices
Are some things not working? Or you think you can do better? Only if you are stuck or when you are done, check the following items:
- Do you use
i2c_dt_spec
for the I²C bus and address? - Do you use
gpio_dt_spec
for the interrupt line? - Did you configure the interrupt line as an input?
- Did you register the interrupt callback before the first measure?
- Did you use a work queue to perform the I²C data reading transaction in order not to do it in an interrupt callback?
- Did you reset the LSM6DSL to ensure that the interrupt line is low initially?
- In order to read the accelerometer data with one I²C transaction, did you keep the auto-increment of addresses enabled?
- Did you use the logging module in order to selectively display data?
Combining sensors
Now that you are able to read the accelerometer data, you should be able to read the gyroscope data as well. We will use both sensors to get an estimate of the various angles around X, Y, and Z axis.
Determine the partial board attitude
Using only the accelerometer, determine the board tilt angles (the angles compared from the "flat-on-the-table" position). You will have to use some maths there. Check that it looks consistent with the position of the board.
Note that the accelerometer-based method works best if the board is not moving fast. Shaking the board will give very bad results.
Read the gyroscope as well
Configure the gyroscope for continuous reading. The same interrupt line will be used to signal data from the accelerometer and data from the gyroscope. Use the STATUS_REG
register to read only the relevant data and display it.
Use the gyroscope to determine the partial board attitude
Assuming that the board is flat on the table when the program starts, use the gyroscope data to compute the board tilt angles. You'll see that the angles derive slowly as each of the axes has a bias. This is inherent to gyroscopes, and many other sensors: they are not perfect.
One characteristic of gyroscopes is that their bias gradually drift over time. However, the bias is usually small enough that, when the board is rotating, it becomes negligible compared to the measured value.
Design a complementary filter between the accelerometer and the gyroscope
Store the accelerometer and gyroscope data in mutex-protected global variables. From a new thread, read those data regularly and compute the new angles using a complementary filter. Choose update frequencies high enough so that the variables are relatively up-to-date.
For example, you can compute the angles by taking 50% of the accelerometer-based computation, and 50% of the gyroscope-based computation (derived from the previous angles). Check if this approach yields results that are both stable and responsive enough. Endeavor to find the best coefficients, ensuring the total amount is is 100%. Don't hesitate to test exterme values (such as 98%/2%).
LED blinking
Make the green leds blink according to the absolute tilt angles. The leds will be steady when the board is flat, and will blink very fast when the board is tilted by 90°.
Writing a device driver
We will now write a Zephyr device-driver for the DM163 led device driver from SiTI. Most of you are already familiar with it, as it is the one present on the RGB matrix board used in SE203.
Our driver will be an out of tree device driver. It means that its source will be kept in the same directory as our application. This is often how drivers are initially built. When they become ready for public consumption, they can be integrated into Zephyr sources.
We will build our device driver so that it is compatible with the led
driver that you have used already. It means that you will be able to use led_on()
, led_off()
and other led-related commands to drive the output of the DM163.
Remember that the DM163 drives only the columns of the led matrix. It is not aware of what a row is, it only knows columns of 8 RGB leds. In order to try our driver, we will have to turn one line transistor on.
💥 As creating a device driver may seem a complicated task at first (it isn't), you will be guided step-by-step. Ensure you don't miss any step and ask questions early.
Structure
Create a new directory dm163_example
to host your new application and driver code. Once we are done, the file structure will look like this:
dm163_example
├── boards
│ └── disco_l475_iot1.overlay
├── CMakeLists.txt
├── dm163_module
│ └── zephyr
│ ├── CMakeLists.txt
│ ├── dm163.c
│ ├── Kconfig
│ └── module.yml
├── dts
│ └── bindings
│ └── siti,dm163.yaml
├── prj.conf
└── src
└── main.c
(you do not need to create this hierarchy right now, you'll be guided when it is time to do that)
Here are some explanations of the structure:
- You are already familiar with
CMakeLists.txt
,prj.conf
andsrc/main.c
. - The
dm163_module
directory contains the DM163 device driver. - The
dts/bindings
directory contains thesiti,dm163.yaml
file which describes what is permitted and what is required in a node marked compatible with our device driver, whose name is, as you guessed,siti,dm163
(from its maker SiTI and its model DM163). boards/disco_l475_iot1.overlay
contains the device-tree part describing how the DM163 device is connected to our board.
From now on, all file names we indicate in the instructions will be relative to the top of the dm163_example
folder.
What is in Zephyr's struct device
?
A struct device
in Zephyr is made of several fields, including:
- A
config
pointer, which points to read-only data describing the configuration gathered from the device tree and theKconfig
options. In our case, the configuration will be a structure containing information about the pins to use in order to drive the DM163 chip. - A
data
pointer, which points to data describing the current state of the device driver. In our case, it will contain the brightness and color information for the leds. - A
api
pointer, which points to a read-only data structure compatible with the driver we want to be compatible with. Here we want to be compatible with theled
driver API, so ourapi
field will be aconst struct led_driver_api * const
.
Most functions will receive a struct device *
parameter which you can use to extract the config
and data
field containing the information you need. For example, to turn on one of the leds controlled by the DM163, you will modify the state of the led in the data
structure and send the new led configuration using pins whose configuration is located in the config
structure.
There exist other fields to store power management operations if the device can be put to sleep, as well as the initialization function to call to initialize the device.
Configuration
Our configuration structure for the DM163 driver will look like this:
struct dm163_config {
const struct gpio_dt_spec en;
const struct gpio_dt_spec gck;
const struct gpio_dt_spec lat;
const struct gpio_dt_spec rst;
const struct gpio_dt_spec selbk;
const struct gpio_dt_spec sin;
};
Those names correspond to the name of the pins in the DM163 documentation. We want to fill this structure from data coming from the device tree.
Create a dts/bindings/siti,dm163.yaml
file containing the following description:
description: SITI DM163 LED controller node
compatible: "siti,dm163"
properties:
sin-gpios:
type: phandle-array
required: true
selbk-gpios:
type: phandle-array
required: true
lat-gpios:
type: phandle-array
required: true
gck-gpios:
type: phandle-array
required: true
rst-gpios:
type: phandle-array
required: true
en-gpios:
type: phandle-array
required: false
This file contains information about what may be and must be present in a device-tree node marked as being compatible with the "siti,dm163" device driver. The phandle-array
type corresponds to a list of phandles and 32-bit cells (usually specifiers), for example <&dma0 2>,<&dma0 3> (according to Zephyr documentation). In our case, we will have only one phandle and its corresponding information, such as <&gpioc 5 0>
.
Some pins names end with "_b" in the documentation. It means that they are active low (when pulled to ground) instead of active high (when pulled to VCC). In our case, we wil note those pins as GPIO_ACTIVE_LOW
in the device tree: setting them to ACTIVE
will pull them to ground and setting them to INACTIVE
will pull them to VCC.
Also note that we named the properties -gpios
; even though we only have one GPIO, it is customary to end the name in s
if the type allows you to enter several values.
Populate the boards/disco_l475_iot1.overlay
with the following hardware information which corresponds to how the led matrix DM163 is connected to our microcontroller:
/ {
dm163: dm163 {
compatible = "siti,dm163";
selbk-gpios = <&gpioc 5 0>;
lat-gpios = <&gpioc 4 GPIO_ACTIVE_LOW>;
rst-gpios = <&gpioc 3 GPIO_ACTIVE_LOW>;
gck-gpios = <&gpiob 1 0>;
sin-gpios = <&gpioa 4 0>;
};
};
Here, we define a node /dm163
with node label dm163
. As we declare it to be compatible with the siti,dm163
device driver (which we still have to write), the node content is checked against the bindings file we created just before (dts/bindings/siti,dm163.yaml
).
Note that en-gpios
is absent. It was marked as required: false
in the bindings description. This pin is sometimes connected directly to the ground, which means that the DM163 output is always enabled. If it is present in the device tree, we will have to configure it as an output and make it low, if it is absent we have nothing to do to enable the DM163 outputs.
Reading the device-tree into the struct dm163_config
Let's create dm163_module/zephyr/dm163.c
with the following content:
// Enter the driver name so that when we initialize the (maybe numerous)
// DM163 peripherals we can designated them by index.
#define DT_DRV_COMPAT siti_dm163
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/led.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(dm163, LOG_LEVEL_DBG);
struct dm163_config {
const struct gpio_dt_spec en;
const struct gpio_dt_spec gck;
const struct gpio_dt_spec lat;
const struct gpio_dt_spec rst;
const struct gpio_dt_spec selbk;
const struct gpio_dt_spec sin;
};
#define CONFIGURE_PIN(dt, flags) \
do { \
if (!device_is_ready((dt)->port)) { \
LOG_ERR("device %s is not ready", (dt)->port->name); \
return -ENODEV; \
} \
gpio_pin_configure_dt(dt, flags); \
} while (0)
static int dm163_init(const struct device *dev) {
const struct dm163_config *config = dev->config;
LOG_DBG("starting initialization of device %s", dev->name);
// Disable DM163 outputs while configuring if this pin
// is connected.
if (config->en.port) {
CONFIGURE_PIN(&config->en, GPIO_OUTPUT_INACTIVE);
}
// Configure all pins. Make reset active so that the DM163
// initiates a reset. We want the clock (gck) and latch (lat)
// to be inactive at start. selbk will select bank 1 by default.
CONFIGURE_PIN(&config->rst, GPIO_OUTPUT_ACTIVE);
CONFIGURE_PIN(&config->gck, GPIO_OUTPUT_INACTIVE);
CONFIGURE_PIN(&config->lat, GPIO_OUTPUT_INACTIVE);
CONFIGURE_PIN(&config->selbk, GPIO_OUTPUT_ACTIVE);
CONFIGURE_PIN(&config->sin, GPIO_OUTPUT);
k_usleep(1); // 100ns min
// Cancel reset by making it inactive.
gpio_pin_set_dt(&config->rst, 0);
// Enable the outputs if this pin is connected.
if (config->en.port) {
gpio_pin_set_dt(&config->en, 1);
}
LOG_INF("device %s initialized", dev->name);
return 0;
}
// Macro to initialize the DM163 peripheral with index i
#define DM163_DEVICE(i) \
\
/* Build a dm163_config for DM163 peripheral with index i, named */ \
/* dm163_config_/i/ (for example dm163_config_0 for the first peripheral) */ \
static const struct dm163_config dm163_config_##i = { \
.en = GPIO_DT_SPEC_GET_OR(DT_DRV_INST(i), en_gpios, {0}), \
.gck = GPIO_DT_SPEC_GET(DT_DRV_INST(i), gck_gpios), \
.lat = GPIO_DT_SPEC_GET(DT_DRV_INST(i), lat_gpios), \
.rst = GPIO_DT_SPEC_GET(DT_DRV_INST(i), rst_gpios), \
.selbk = GPIO_DT_SPEC_GET(DT_DRV_INST(i), selbk_gpios), \
.sin = GPIO_DT_SPEC_GET(DT_DRV_INST(i), sin_gpios), \
}; \
\
DEVICE_DT_INST_DEFINE(i, &dm163_init, NULL, NULL, &dm163_config_##i, \
POST_KERNEL, CONFIG_LED_INIT_PRIORITY, NULL);
// Apply the DM163_DEVICE to all DM163 peripherals not marked "disabled"
// in the device tree and pass it the corresponding index.
DT_INST_FOREACH_STATUS_OKAY(DM163_DEVICE)
Since you can have several identical peripheral on a board, they will be identified by an index. The DT*_INST_*
macros work with peripherals of type DT_DRV_COMPAT
(which we defined at the top of the file). Here, the DM163_DEVICE
macro will be applied to each peripheral marked compatible with the siti_dm163
driver. Note how the ,
from the device tree (in "siti,dm163") as been replaced by a _
for compatibility with the C language.
You have probably understood arleady what DT_DRV_INST(i)
does: it designates the node in the device tree containing instance i
of driver DT_DRV_COMPAT
. Also, DEVICE_DT_INST_DEFINE()
creates an instance of the struct device
. After those macros have executed, you end up with having statically created a struct device
for every instance of the DM163 peripherals.
The other interesting parameters are:
POST_KERNEL
: the driver must be initialized after the kernel has started because we use thek_sleep()
kernel service indm163_init()
.CONFIG_LED_INIT_PRIORITY
: amongst all the drivers initialized in thePOST_KERNEL
phase, initialize this one at the same time as the other led drivers. We could have defined our own initialization piority but there is no need to do so.
Enabling the device driver
We need to create a Kconfig
file in dm163_module/zephyr
which adds a new CONFIG_DM163_DRIVER
option. Let's make this option be selected by default as soon as there is a peripheral wanting to use the siti,dm163
driver in the device tree. In this case, CONFIG_DT_HAS_SITI_DM163_ENABLED
will be defined automatically by the device tree compiler. Also, we need to ensure that CONFIG_GPIO
and CONFIG_LED
are selected when we use this driver, as we use gpios and will make our driver compatible with the led driver. Here is the content of dm163_module/zephyr/Kconfig
:
config DM163_DRIVER
bool "Support for the DM163 led driver"
default y
depends on DT_HAS_SITI_DM163_ENABLED
select GPIO
select LED
There are two steps needed to complete our module. Create a file for cmake
in dm163_module/zephyr/CMakeLists.txt
indicating where the module sources are:
if(CONFIG_DM163_DRIVER)
# Unused for now as we do not have any .h, but we might need to add
# .h files later for this driver
zephyr_include_directories(.)
zephyr_library()
zephyr_library_sources(dm163.c)
endif()
and a dm163_module/zephyr/module.yml
which indicates where the directory with the cmake file and the Kconfig
file are located relative to the module directory:
build:
cmake: zephyr
kconfig: zephyr/Kconfig
We must now indicate to the top-level cmake where our module can be found if we need it by appending the dm163_module
to the list of Zephyr extra modules. Use the following content in CMakeLists.txt
:
cmake_minimum_required(VERSION 3.20.0)
list(APPEND ZEPHYR_EXTRA_MODULES
${CMAKE_CURRENT_SOURCE_DIR}/dm163_module
)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(dm163_example)
target_sources(app PRIVATE src/main.c)
Let's now configure our project in prj.conf
and add some logging capabilities:
CONFIG_LOG=y
Our main program in src/main.c
does not need to be long:
int main() {
return 0;
}
It is now time to build our project with west build -b disco_l475_iot1
and to flash it with west flash --runner jlink --reset
. If everything went well, you should see the following on the USB serial port (for example /dev/ttyACM0
):
*** Booting Zephyr OS build zephyr-v3.2.0-714-g7288df4c41d3 ***
[00:00:00.000,000] <dbg> dm163: dm163_init: starting initialization of device dm163
[00:00:00.000,000] <inf> dm163: device dm163 initialized
Yeah, we have the beginning of a device driver. Let's continue.
Dynamic data
As we defined the struct dm163_config
to hold read-only configuration information about a peripheral, we now need to define a struct dm163_data
to hold dynamic information.
The DM163 has two data banks:
- Bank 0 contains 6 bits per channel (a channel is the red, green, or blue color for one RGB led).
- Bank 1 contains 8 bits per channel.
The PWM value presented on a given channel will be proportional to (bank0÷64)×(bank1÷256). We will use bank 0 to represent the brightness of the led and bank 1 to represent the color intensity.
Typically, all 24 channels (for 8 leds) will have the same brightness. It is useful to reduce the overall power.
This gives us the structure of our struct dm163_data
:
#define NUM_LEDS 8
#define NUM_CHANNELS (NUM_LEDS * 3)
struct dm163_data {
uint8_t brightness[NUM_CHANNELS];
uint8_t channels[NUM_CHANNELS];
};
We can now extend our DM163_DEVICE
macro so that every struct device
also contains a struct dm163_data
to represent the dynamic data of a given DM163 peripheral:
// Macro to initialize the DM163 peripheral with index i
#define DM163_DEVICE(i) \
\
/* Build a dm163_config for DM163 peripheral with index i, named */ \
/* dm163_config_/i/ (for example dm163_config_0 for the first peripheral) */ \
static const struct dm163_config dm163_config_##i = { \
.en = GPIO_DT_SPEC_GET_OR(DT_DRV_INST(i), en_gpios, {0}), \
.gck = GPIO_DT_SPEC_GET(DT_DRV_INST(i), gck_gpios), \
.lat = GPIO_DT_SPEC_GET(DT_DRV_INST(i), lat_gpios), \
.rst = GPIO_DT_SPEC_GET(DT_DRV_INST(i), rst_gpios), \
.selbk = GPIO_DT_SPEC_GET(DT_DRV_INST(i), selbk_gpios), \
.sin = GPIO_DT_SPEC_GET(DT_DRV_INST(i), sin_gpios), \
}; \
\
/* Build a new dm163_data_/i/ structure for dynamic data */ \
static struct dm163_data dm163_data_##i = {}; \
\
DEVICE_DT_INST_DEFINE(i, &dm163_init, NULL, &dm163_data_##i, \
&dm163_config_##i, POST_KERNEL, \
CONFIG_LED_INIT_PRIORITY, NULL);
Let's initialize our data by adding the following fragments in dm163_init()
. Right after the definition of config
, we'll define data
and retrieve it from the const struct device *dev
that we received:
struct dm163_data *data = dev->data;
Then right after we mark the config->rst
pin as inactive and before we enable config->en
, we'll initialize the data and send it to the DM163 peripheral:
memset(&data->brightness, 0x3f, sizeof(data->brightness));
memset(&data->channels, 0x00, sizeof(data->channels));
flush_brightness(dev);
flush_channels(dev);
Here we set the brightness of every channel to its maximum 0x3f, or 2⁶-1, and the color value to 0. Then we send all the brightness and channel information to the DM163: all leds are off.
Of course we'll have to write those flush_brightness()
and flush_channels()
functions.
Utility functions
Write the following functions.
Sending data to the DM163
static void pulse_data(const struct dm163_config *config, uint8_t data, int bits);
This function sends bits
bits of data
, MSB first. Sending a bit means settings its value on config->sin
, and making config->gck
active then inactive (clock pulse).
For example pulse_data(config, data, 6)
will send bit 5 of data, then bit 4, …, and end with bit 0. pulse_data(config, data, 8)
will send bit 7 of data, then bit 6, …, and end with bit 0.
Sending channel information
static void flush_channels(const struct device *dev) {
const struct dm163_config *config = dev->config;
struct dm163_data *data = dev->data;
for (int i = NUM_CHANNELS - 1; i >= 0; i--)
pulse_data(config, data->channels[i], 8);
gpio_pin_set_dt(&config->lat, 1);
gpio_pin_set_dt(&config->lat, 0);
}
This function sends all channel information (in reverse order) to bank 1 and then does a latch pulse by setting config->lat
as active (ground, because this pin is active low) then inactive (VCC). We also assume that the current selected bank (on config->sb
) is 1 when we enter this function.
The code of the function is given to show the standard way of working with the config
and data
members of the struct device
structure. By storing them into correctly typed variable (while they are of type void *
in struct device
), we can easily reference their fields, such as config->lat
or data->channels
.
Sending brightness information
static void flush_brightness(const struct device *dev);
This function sends the brightness information for all the channels (in reverse order) to bank 0. Bank 0 must be selected first, and bank 1 must be selected back at the end of the function so that when we return bank 1 is selected again. The rationale here is that we are more likely to frequently change colors rather than change the brightness of the leds.
Setting brightness for a led
static int dm163_set_brightness(const struct device *dev, uint32_t led, uint8_t value);
This function receives a brightness between 0 and 100 (inclusive) in value
. It must set it in data->brightness
for the three channels of the corresponding led
after converting the value
to the [0-63] interval. Once the brightness has been set, flush_brightness()
must be called for this to be reflected on the actual leds.
The return value is 0 if all goes well, -EINVAL
if the led number is incorrect.
Turning a led on and off
static int dm163_on(const struct device *dev, uint32_t led);
static int dm163_off(const struct device *dev, uint32_t led);
Those functions respectively turn a led on (full white) and off. Once the channels
have been updated, flush_channels()
must be called for this to be reflected on the actual leds.
Being compatible with the led API
We now have enough functions to be able to pretend to be compatible with Zephyr led driver API. Before the DM163_DEVICE()
macro, build a read-only struct led_driver_api
named dm163_api
:
static const struct led_driver_api dm163_api = {
.on = dm163_on,
.off = dm163_off,
.set_brightness = dm163_set_brightness,
};
Here we define three functions that can be called by the LED api functions (respectively led_on()
, led_off()
and led_set_brightness()
). We let other functions be undefined (NULL) for the time being.
Update the DEFINE_DT_INST_DEFINE()
call to include this newly defined structure as the last argument:
DEVICE_DT_INST_DEFINE(i, &dm163_init, NULL, &dm163_data_##i, \
&dm163_config_##i, POST_KERNEL, \
CONFIG_LED_INIT_PRIORITY, &dm163_api);
Using the LED API and testing our code
We can now build a real main program:
#include <zephyr/device.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/led.h>
#define DM163_NODE DT_NODELABEL(dm163)
static const struct device *dm163_dev = DEVICE_DT_GET(DM163_NODE);
int main() {
if (!device_is_ready(dm163_dev)) {
return -ENODEV;
}
// Set brightness to 5% for all leds so that we don't become blind
for (int i = 0; i < 8; i++)
led_set_brightness(dm163_dev, i, 5);
// Animate the leds
for (;;) {
for (int col = 0; col < 8; col++) {
led_on(dm163_dev, col);
k_sleep(K_MSEC(100));
led_off(dm163_dev, col);
}
}
}
Execute the program. Does it work?
If it does, think about how strange this is: you haven't enabled any line transistor and yet, one line is enabled. How comes? Since CONFIG_DM163_DRIVER
, which is implicit because you have declared one such peripheral is the device tree, implies CONFIG_LED
, then the GPIO leds are configured. And one of those leds correspond to the same pin as one of the lines. You got lucky.
Using the shell
If you activate the CONFIG_SHELL
and CONFIG_LED_SHELL
options, you'll see a led
menu in the shell that can be used to turn the leds on or off:
uart:~$ led on dm163 4
dm163: turning on LED 4
uart:~$ led off dm163 4
dm163: turning off LED 4
Driving the lines
We want to add the information about the 8 lines to the device tree. Let's invent a new node type to do this, we'll call it "rgb_matrix".
Add a dts/bindings/rgb_matrix.yaml
file:
description: RGB matrix
compatible: "rgb_matrix"
properties:
rows-gpios:
type: phandle-array
required: true
Any node identified as compatible with "rgb_matrix" in the device tree will need to have a row-gpios
property in which we will store the lines information.
Let's add such a node in our board overlay boards/disco_l475_iot1.overlay
which now becomes:
/ {
dm163: dm163 {
compatible = "siti,dm163";
selbk-gpios = <&gpioc 5 0>;
lat-gpios = <&gpioc 4 GPIO_ACTIVE_LOW>;
rst-gpios = <&gpioc 3 GPIO_ACTIVE_LOW>;
gck-gpios = <&gpiob 1 0>;
sin-gpios = <&gpioa 4 0>;
};
rgb_matrix: rgb_matrix {
compatible = "rgb_matrix";
rows-gpios = <&gpiob 2 0>, <&gpioa 15 0>, <&gpioa 2 0>, <&gpioa 7 0>,
<&gpioa 6 0>, <&gpioa 5 0>, <&gpiob 0 0>, <&gpioa 3 0>;
};
};
Using GPIO_DT_SPEC_GET_BY_IDX
, we can retrieve one item from the phandle-array by its index. Here is the new content of main.c
:
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/led.h>
#include <zephyr/kernel.h>
#define DM163_NODE DT_NODELABEL(dm163)
static const struct device *dm163_dev = DEVICE_DT_GET(DM163_NODE);
#define RGB_MATRIX_NODE DT_NODELABEL(rgb_matrix)
BUILD_ASSERT(DT_PROP_LEN(RGB_MATRIX_NODE, rows_gpios) == 8);
static const struct gpio_dt_spec rows[] = {
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 0),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 1),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 2),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 3),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 4),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 5),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 6),
GPIO_DT_SPEC_GET_BY_IDX(RGB_MATRIX_NODE, rows_gpios, 7),
};
int main() {
if (!device_is_ready(dm163_dev)) {
return -ENODEV;
}
for (int row = 0; row < 8; row++)
gpio_pin_configure_dt(&rows[row], GPIO_OUTPUT_INACTIVE);
// Set brightness to 5% for all leds so that we don't become blind
for (int i = 0; i < 8; i++)
led_set_brightness(dm163_dev, i, 5);
// Animate the leds on every row and every column
for (;;) {
for (int row = 0; row < 8; row++) {
gpio_pin_set_dt(&rows[row], 1);
for (int col = 0; col < 8; col++) {
led_on(dm163_dev, col);
k_sleep(K_MSEC(30));
led_off(dm163_dev, col);
}
gpio_pin_set_dt(&rows[row], 0);
}
}
}
Nice, isn't it? Notice the BUILD_ASSERT()
macro which checks at compile time that the rows-gpios
property contains exactly eight items.
Why have we created a new node type instead of adding a property to the "siti,dm163" node? Because this has nothing to do with the DM163 which can perfectly be used to drive a single line of led.
More functions
Some other functions need to be implemented so that the driver can be fully (color mode) and efficiently driven.
Set led color
int dm163_set_color(const struct device *dev, uint32_t led, uint8_t num_colors,
const uint8_t *color);
This function sets a led color. color
contains the red, green, and blue component, in order. If some are missing, you can assume them to be 0. Don't forget to send the new channels value to the chip after doing so. As always, an incorrect led number or too great the number of colors will return -EINVAL
instead of 0.
This corresponds to the set_color
entry in the struct led_driver_api
definition.
Set channels in bulk
int dm163_write_channels(const struct device *dev, uint32_t start_channel,
uint32_t num_channels, const uint8_t *buf);
Write channels in bulk. Make sure you check the bounds. This corresponds to the write_channels
entry in the struct led_driver_api
definition.
Using the functions
You can now use the shell led set_color dm163 4 0 0 255
to display a beautiful blue led. Of course, it might be easier to stop the animation first to see it, and activate only one line.
Driving the whole matrix
Create a new image structure made of 8×3×8 channels representing the whole led matrix. Make a thread which waits on a semaphore and display the next line using led_write_channels()
when the semaphore is signaled. Make a timer signal the semaphore approximately every 1÷(8×60) second. Enjoy your beautiful image.
Prevent race conditions
There is a risk that some thread changes the brightness (and sends the brightness information to the DM163) while another thread is changing a channel color value (and sends this information to the DM163): both threads will try to talk to the DM163 at the same time.
Add a mutex to the dm163_data
structure, and take the mutex before flushing the colors or the brightness to the DM163.
To go further
Add a function with the following prototype in dm163_module/zephyr/dm163.h
:
void dm163_turn_off_row(const struct device *dev, const struct gpio_dt_spec *row);
(with the missing includes, and protection against multiple inclusion)
This function will remember the given row
in the dm163_data
structure. The next time flush_channels()
is sending data to the DM163, as soon as data for the first six leds have been sent, the given row
GPIO will be turned off. Then data for the remaining two leds will be sent and row
will be forgotten by storing NULL
instead.
By using this function, you might be able to turn a row off soon enough so that it has time to turn off completely before new data is latched, and late enough to keep a maximum of brightness. For example, this code
gpio_pin_set_dt(&rows[row], 0); // Turn row "row" off
led_write_channels(&dm163_dev, …); // Send data for row "row+1"
gpio_pin_set_dt(&rows[row+1], 1); // Turn row "row+1" on
would be replaced by
dm163_turn_off_row(&dm163_dev, &rows[row]); // Remember the row to turn off
led_write_channels(&dm163_dev, …); // Send data for row "row+1". As a side
// effect, row "row" will be turned off
// when 6/8th of the data has been sent.
gpio_pin_set_dt(&rows[row+1], 1);
and give a better visual effect.
Spirit level
Now that you can drive the led matrix and that you know how to compute the tilt levels on the X and Y axes, you should be able to build a virtual spirit level. The display should reflect the way the board is tilted, any way you like (a ball, a liquid like behavior, etc.).