Build a Reactive Light Controller
Program an STM32 board with PWM, UART, and hardware interrupts.
Introduction
30 Second Summary
Every smart device around you runs on a tiny computer called a microcontroller. From dimming desk lamps to car dashboards, these chips respond to button presses, sensor readings, and commands in real time.
In this project, you will program an STM32 microcontroller to control an LED with smooth brightness fading, respond to button presses using hardware interrupts, and communicate with your PC over a serial connection.
What You'll Build
A reactive light controller running on an STM32 Nucleo board that combines PWM-driven LED animations, interrupt-based button input, and bidirectional UART communication with your PC.
By the end of this project, you'll have:
- An STM32CubeMX project with GPIO, Timer/PWM, UART, and interrupt peripherals configured and working together.
- A PWM breathing effect that smoothly fades an LED in and out using hardware timers.
- A UART command interface that lets you send commands from your PC to change light modes in real time, and a button that cycles through modes using hardware interrupts.
- Secret Mission: Add a "disco mode" that automatically cycles through all patterns with speed controlled from your terminal.
Are there any prerequisites?
You should have basic C programming knowledge (variables, loops, functions) and a general understanding of what a microcontroller is. You will also need a NUCLEO-F401RE development board and a USB Micro-B cable to connect it to your PC.
Before We Start
Before We Start...
Install STM32 Development Tools
Before writing any firmware, you need two tools from STMicroelectronics on your machine. STM32CubeMX handles peripheral configuration and code generation. STM32CubeIDE handles coding, compiling, and flashing your board.
Since STM32CubeIDE 2.0.0, these are standalone interoperable tools. You configure peripherals in CubeMX, generate code, then import the project into CubeIDE to write and debug your firmware.
In this step, get ready to:
- Install STM32CubeMX for peripheral configuration and code generation.
- Install STM32CubeIDE 2.1.0 for coding, building, and debugging.
- Connect the NUCLEO-F401RE board and verify the ST-LINK driver is detected.
Download and install STM32CubeMX
STM32CubeMX is a graphical tool that lets you select your board, configure pins, clocks, and peripherals, then generate initialization code automatically. You will need a free ST account to download it.
- Open your browser and navigate to the STM32CubeMX download page.
- Click **Get Software** and select the installer for your operating system (Windows, macOS, or Linux).
- If prompted, create a free myST account or sign in with your existing credentials.
- Once the download completes, run the installer and accept the default settings.
- When the installation finishes, launch STM32CubeMX to confirm it opens without errors.
What is STM32CubeMX?
STM32CubeMX is a standalone configuration tool from STMicroelectronics. You select your MCU or development board, visually configure the pins and peripherals, and it generates all the initialization C code for you.
This means you do not have to manually write register-level setup code. CubeMX produces well-structured, HAL-based init code that you then extend with your own application logic.
Download not starting?
ST's website requires you to accept a license agreement before the download begins. Look for a checkbox or button labeled Accept at the bottom of the page.
If you still cannot download, try a different browser or disable any ad blockers temporarily. Help me troubleshoot the STM32CubeMX download.
Download and install STM32CubeIDE 2.1.0
STM32CubeIDE is the Eclipse-based IDE where you write, compile, and flash your C code. This project requires version 2.1.0 or higher because it uses the standalone CubeMX workflow and includes the ST-LINK drivers.
- Navigate to the STM32CubeIDE download page.
- Click Get Software and download the installer for your operating system.
- Run the installer and accept the default options. On Windows, allow the ST-LINK driver installation when prompted.
Why does the installer ask about drivers?
The STM32CubeIDE installer bundles the ST-LINK USB drivers. These drivers let your computer communicate with the debugger built into your Nucleo board over the same USB cable you use for programming.
Always accept the driver installation. Without it, you will not be able to flash or debug your firmware.
After the installation completes, check your version to make sure you have the right one.
✔️ I have version 2.1.0 or higher
You are all set. Close the IDE for now.
ⓧ I have an older version
Older versions of STM32CubeIDE still embed CubeMX internally, which conflicts with the standalone workflow this project uses.
- Uninstall your current STM32CubeIDE from Add or Remove Programs (Windows) or by dragging it to the Trash (macOS).
- Download the 2.1.0 installer from the STM32CubeIDE download page and run it.
ⓧ I don't have it installed
You already downloaded the installer in the instructions above. Run it now, accept defaults, and allow the ST-LINK driver installation on Windows.
- On macOS, you may see a security prompt asking you to allow apps from an identified developer. Open System Settings then Privacy & Security and click Open Anyway.
- Once installed, launch STM32CubeIDE once to confirm it opens, then close it.
Connect the board and install a serial terminal
Your NUCLEO-F401RE board has a built-in ST-LINK debugger that connects to your PC over a single USB Micro-B cable. This same cable provides power, programming, and a virtual serial port for UART communication.
- Plug the USB Micro-B cable into the CN1 connector on the Nucleo board (the USB connector near the ST-LINK section at the top).
- Connect the other end to a USB port on your computer.
- Wait a few seconds for the driver to initialize. The green LED labeled LD1 (COM) on the board should light up or blink, indicating the ST-LINK is communicating.
Now verify that the ST-LINK Virtual COM Port is detected on your computer.
Windows
- Press the Windows key, type Device Manager, and press Enter.
- Expand Ports (COM & LPT).
- You should see an entry labeled STMicroelectronics STLink Virtual COM Port with a COM number (e.g., COM3).
Note down the COM port number. You will need it later when connecting PuTTY.
macOS
- Open Terminal by pressing Cmd+Space and typing Terminal.
- List available serial ports by running:
ls /dev/cu.usbmodem*
You should see an entry like /dev/cu.usbmodemXXXX. This is your board's serial port. Note it down for later.
Don't see the serial port?
Make sure the USB cable is plugged into the ST-LINK connector (top of the board), not the User USB connector.
On Windows, if the port does not appear, try unplugging and replugging the cable. The STM32CubeIDE installer includes the ST-LINK drivers, so reinstalling the IDE with driver installation enabled usually resolves this.
On macOS, you may see a prompt asking to allow an accessory to connect. Click Allow. Help me troubleshoot my board connection.
Finally, install a serial terminal so you can talk to the board over UART in later steps.
Windows
- Open your browser and go to the PuTTY download page.
- Download the 64-bit MSI installer (putty-64bit-0.84-installer.msi).
- Run the installer and accept the defaults.
- Verify by pressing the Windows key, typing PuTTY, and confirming it appears in your search results.
What is PuTTY?
PuTTY is a free terminal application that can open serial connections. You will use it to send and receive data between your PC and the Nucleo board over the ST-LINK Virtual COM Port.
macOS
macOS includes the screen command built into Terminal, which can open serial connections. No additional software is needed.
When this project says to open PuTTY, you will use screen instead. The exact command will be provided in the step where you connect.
Your development environment is ready. Next up, you will configure the peripherals on your Nucleo board using STM32CubeMX to set up the LED, button, timer, and UART.
Configure Peripherals in STM32CubeMX
Your development tools are installed and the board is connected. Now it is time to tell the microcontroller what each of its pins should do.
STM32CubeMX is a graphical configuration tool that lets you assign pin functions, set clock speeds, and enable peripherals without writing any register code. In this step, you will configure the four peripherals your light controller needs: a timer for PWM output, a serial port for PC communication, a button interrupt for real-time input, and the system clock to run at full speed.
In this step, get ready to:
- Create a new CubeMX project for the NUCLEO-F401RE board.
- Configure PWM output and the 84 MHz system clock.
- Enable UART communication and hardware interrupts.
Create a new STM32CubeMX project
When you select a board in CubeMX, it automatically pre-configures the pins that are connected to built-in hardware like the LED and user button. This saves you from manually checking the board schematic.
- Open STM32CubeMX.
- Click File in the top menu bar, then click New Project.
- In the New Project window, click the Board Selector tab.
- Type NUCLEO-F401RE into the search field.
- Select the NUCLEO-F401RE board from the results list.
- Click Start Project.
- A popup asks Initialize all peripherals with their default Mode? Click Yes.
CubeMX opens the Pinout & Configuration view. You can see the chip diagram with pre-assigned pins highlighted in green.
- In the left panel under System Core, click SYS.
- Verify that Debug is set to Serial Wire. This is required for flashing and debugging over the ST-LINK USB connection.
What did selecting the board do?
By choosing the NUCLEO-F401RE board instead of a bare MCU, CubeMX auto-configured PA5 (the green LED LD2) as a GPIO output and PC13 (the user button B1) as a GPIO input. You will reconfigure PA5 for PWM in the next substep.
Don't see NUCLEO-F401RE in the list?
Make sure you are in the Board Selector tab, not the MCU Selector. If the board still does not appear, CubeMX may need to download the STM32F4 firmware package. Check your internet connection and try again.
help me find the NUCLEO-F401RE board in STM32CubeMX
Configure TIM2 for PWM and set the system clock
The LED on PA5 is connected to TIM2 Channel 1 inside the chip. By setting this pin to PWM mode, you can control LED brightness with a fast on/off signal instead of a simple on or off.
- In the left panel, expand Timers and click TIM2.
- Under Clock Source, select Internal Clock.
- Under Channel1, select PWM Generation CH1. Pin PA5 on the chip diagram turns to an alternate-function color.
Before setting the timer parameters, you need to configure the system clock so the timer math is correct.
- Click the Clock Configuration tab at the top of the CubeMX window.
- Verify the Input frequency shows HSI (16 MHz internal oscillator) as the source.
- Enable the PLL path by selecting PLLCLK as the System Clock Mux source.
- Set the HCLK field to 84 MHz and press Enter. CubeMX auto-calculates the PLL multipliers and dividers.
Why 84 MHz?
The STM32F401RE supports up to 84 MHz. Running at full speed gives TIM2 an 84 MHz timer clock (since APB1 prescaler doubles the timer clock when the APB1 prescaler is not 1). This makes the PWM frequency math clean.
With an 84 MHz timer clock, a prescaler of 83 divides it to 1 MHz. An auto-reload value of 999 then produces a 1 kHz PWM signal with 1000 brightness levels (0 to 999).
- Click the Pinout & Configuration tab to return to the peripheral view.
- Click TIM2 in the left panel again.
- In the Parameter Settings tab on the right, set Prescaler to 83.
- Set Counter Period (AutoReload Register) to 999.
What do prescaler and counter period mean?
The prescaler divides the timer input clock. The formula is: counter clock = timer clock / (prescaler + 1). With prescaler = 83, you get 84 MHz / 84 = 1 MHz.
The counter period (ARR) defines how high the counter counts before resetting. PWM frequency = counter clock / (ARR + 1) = 1 MHz / 1000 = 1 kHz. The duty cycle ranges from 0 (LED off) to 999 (LED fully on).
Clock Configuration shows an error?
If CubeMX highlights the clock tree in red, it means the requested frequency is not achievable with the current PLL settings. Delete any manually typed PLL values and type 84 into the HCLK field. Let CubeMX solve the PLL chain automatically.
help me fix my STM32CubeMX clock configuration
Enable USART2 and configure interrupts
The NUCLEO-F401RE routes USART2 (PA2/PA3) through the ST-LINK chip to your PC over the same USB cable you use for programming. This means you can send and receive serial data without any extra hardware.
- In the left panel, expand Connectivity and click USART2.
- Set Mode to Asynchronous. Pins PA2 (TX) and PA3 (RX) should already be assigned from the board defaults.
- In the Parameter Settings tab, verify the following values:
- Click the NVIC Settings tab (next to Parameter Settings).
- Check the box to enable the USART2 global interrupt. This lets the MCU notify your code the instant a byte arrives, instead of wasting cycles polling.
Now configure the button interrupt so pressing B1 triggers your code immediately.
- In the left panel, expand System Core and click GPIO.
- In the pin list, find PC13 and click it. Verify its GPIO mode is set to External Interrupt Mode with Falling edge trigger detection. This fires the interrupt when the button is pressed (signal goes low).
- In the left panel, expand System Core and click NVIC.
- Check the box for EXTI line[15:10] interrupts to enable the interrupt that covers PC13 (EXTI line 13).
What is EXTI?
EXTI stands for External Interrupt/Event Controller. It monitors GPIO pins and fires an interrupt when it detects a signal edge (rising or falling). PC13 is on EXTI line 13, which is grouped into the EXTI15_10 interrupt vector.
When you press the user button, the voltage on PC13 drops from high to low (falling edge). The EXTI controller detects this and immediately pauses your main code to run your interrupt handler.
PC13 not showing as an external interrupt?
If PC13 shows as a plain GPIO input, click directly on the PC13 pin in the chip diagram and select GPIO_EXTI13 from the dropdown. Then go to the GPIO settings and set the trigger to falling edge.
help me configure PC13 as an external interrupt in STM32CubeMX
Your peripherals are fully configured. Next up, you will generate the initialization code and import it into STM32CubeIDE to start writing firmware.
Generate Code and Set Up the Project
Your peripheral configuration is complete. You have a TIM2 timer ready for PWM, a USART2 serial port, and an interrupt-enabled button. Now it is time to turn that configuration into actual C code.
STM32CubeMX generates all the initialization code for you based on the settings you configured. You will then import this generated project into STM32CubeIDE where you can compile, flash, and debug your firmware.
In this step, get ready to:
- Set the project name and toolchain in STM32CubeMX Project Manager.
- Generate initialization code from your peripheral configuration.
- Import the generated project into STM32CubeIDE and verify it builds.
Set the project name and generate code
STM32CubeMX needs to know where to output the generated files and which IDE format to target. You will configure these settings in the Project Manager tab.
- In STM32CubeMX, click the Project Manager tab at the top of the window.
- In the Project Name field, enter ReactiveLight.
- In the Project Location field, choose a folder on your Desktop or Documents where you want the project saved.
- Set the Toolchain dropdown to STM32CubeIDE.
- Click the GENERATE CODE button in the toolbar.
- A dialog may appear asking to download firmware packages. Click Yes if prompted. This download may take a few minutes on the first run.
What does GENERATE CODE do?
STM32CubeMX reads all your peripheral settings and produces a complete C project with initialization functions. It creates main.c along with driver files, startup assembly, and linker scripts.
Your custom application code goes between special USER CODE BEGIN and USER CODE END comment markers. If you ever regenerate code later, STM32CubeMX preserves anything inside those markers.
Once code generation finishes, you should see a success dialog confirming the files were created.
Code generation failed?
If you see an error about missing firmware packages, check your internet connection and try clicking GENERATE CODE again. STM32CubeMX downloads the STM32CubeF4 HAL library on first use.
If you see a path error, make sure your Project Location folder exists and the path contains no special characters or spaces.
Help me fix my STM32CubeMX code generation error
Import the project into STM32CubeIDE
Since STM32CubeIDE 2.1.0 no longer embeds STM32CubeMX, you need to import the generated project manually. This is a one-time step for each project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type STM32CubeIDE and press Enter to open it.
- When prompted for a workspace location, accept the default or choose a folder you prefer. Click Launch.
- Click File in the top menu bar.
- Select STM32 Project Create/Import.
- Click Import STM32CubeMX/STM32CubeIDE Project.
- Browse to the folder where STM32CubeMX generated your project (the ReactiveLight folder inside your chosen project location).
- Click Finish.
What is happening during the import?
STM32CubeIDE reads the .project and .cproject files that STM32CubeMX generated. These files tell the IDE where your source files are, which compiler to use, and how to link the firmware binary.
Your project should now appear in the Project Explorer panel on the left side of STM32CubeIDE.
- Verify the project builds by pressing Ctrl+B (Windows) or Cmd+B (macOS).
- Check the Console panel at the bottom. You should see a build log ending with zero errors.
What should I see in the Console?
The build output lists each file being compiled, then a final summary. Look for a line showing the binary size and confirming zero errors, zero warnings (warnings are acceptable, but errors mean something is wrong).
Seeing build errors?
If you see errors about missing include files, right-click the project in Project Explorer and select Refresh (or press F5), then build again.
If the error mentions the toolchain or compiler not found, confirm STM32CubeIDE finished its initial setup. The IDE downloads the ARM GCC compiler on first launch.
Help me fix build errors in my STM32CubeIDE project
Your generated project is ready. The main.c file contains initialization calls to MX_GPIO_Init(), MX_TIM2_Init(), and MX_USART2_UART_Init() along with an empty while (1) loop waiting for your application code. Next up, you will write the PWM breathing effect that brings the LED to life.
Implement PWM Breathing Effect
Your STM32 project is generated, imported, and building cleanly. Now it's time to make the hardware do something visible.
In this step, you will write the firmware that makes the green LED breathe. PWM (Pulse Width Modulation) rapidly switches a pin on and off at a fixed frequency. By varying how long the pin stays "on" in each cycle (the duty cycle), you control perceived brightness without changing voltage.
In this step, get ready to:
- Start the PWM timer output on the LED pin.
- Implement a breathing effect that smoothly ramps brightness up and down.
- Build and flash firmware to verify the LED fades on the board.
Start PWM and create the breathing effect
The generated main.c already initializes TIM2 for PWM, but it never starts the timer or sets a duty cycle. You need to add the code that activates PWM output and continuously adjusts brightness.
- In STM32CubeIDE, open main.c from the Core/Src folder in the Project Explorer on the left.
- Add the following includes and variable declarations near the top of the file, just below #include "main.h":
#include <string.h>
#include <stdio.h>
/* Brightness level for PWM duty cycle (0-999) */
uint16_t brightness = 0;
/* Direction of brightness change: 1 = up, -1 = down */
int8_t direction = 1;
What do these variables do?
The brightness variable holds the current duty cycle value from 0 to 999 (matching the timer's auto-reload value). The direction variable tracks whether brightness is increasing or decreasing.
The string.h and stdio.h includes are not used yet, but you will need them for UART string formatting in the next step.
- Add a function prototype below the other prototypes (after the MX_USART2_UART_Init prototype):
void run_breathing(void);
- Now write the run_breathing() function above main(). Copy the following code:
void run_breathing(void)
{
/* Set PWM duty cycle to current brightness */
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, brightness);
/* Increment or decrement brightness */
brightness += direction * 5;
/* Reverse direction at limits */
if (brightness >= 999)
{
brightness = 999;
direction = -1;
}
else if (brightness == 0 || brightness > 60000) /* underflow check */
{
brightness = 0;
direction = 1;
}
/* Small delay for visible fade effect */
HAL_Delay(10);
}
What does this code do?
Each time run_breathing() runs, it sets the PWM compare value (duty cycle) to the current brightness level, then nudges brightness up or down by 5.
When brightness hits 999 (maximum), it reverses to count down. The brightness > 60000 check catches unsigned integer underflow. Since brightness is a uint16_t, subtracting past zero wraps to a very large number.
The 10ms delay between updates creates a smooth visible fade. A full breath cycle takes about 4 seconds (200 steps up, 200 steps down, 10ms each).
- Inside main(), find the line MX_USART2_UART_Init();. Add this line directly below it to start the PWM timer output:
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
- Find the empty while (1) {} loop in main() and replace it with:
while (1)
{
run_breathing();
}
Why call HAL_TIM_PWM_Start separately?
STM32CubeMX generates the timer initialization code (setting prescaler, period, and PWM mode), but it does not start the timer output. You must explicitly call HAL_TIM_PWM_Start() to activate the PWM signal on the pin.
- Save main.c by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Build and flash the firmware
With the breathing logic in place, you can now compile and flash it onto the Nucleo board to see the LED in action.
- Build the project by clicking the hammer icon in the toolbar, or pressing Ctrl+B.
- Check the Console panel at the bottom of STM32CubeIDE. You should see a message ending with Build Finished. 0 errors.
- Flash the firmware to the board by clicking the green play (Run) button in the toolbar.
What happens during flash?
STM32CubeIDE uses the ST-LINK debugger built into your Nucleo board to program the MCU's flash memory over the same USB cable. The first time you flash, it may ask you to create a Run Configuration. Accept the defaults.
- Look at the green LED (LD2) on your NUCLEO-F401RE board. It should now smoothly fade in and out in a continuous breathing pattern.
LED not breathing?
If the LED stays off, check that HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); appears after all MX_ initialization calls in main(). Starting PWM before the GPIO is configured can silently fail.
If the LED blinks abruptly instead of fading, confirm your TIM2 prescaler is 83 and ARR is 999 in your STM32CubeMX configuration. These values give a 1 kHz PWM frequency, which is fast enough for smooth dimming.
If the build fails, check for typos in the __HAL_TIM_SET_COMPARE macro name (double underscore at the start).
Help me debug my STM32 PWM breathing LED.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.c and the LED is breathing on your board.
ⓧ I'd like to double check the full code
Here is the complete main.c at this point. Compare your file against this:
/* USER CODE BEGIN Header */
/**
******************************************************************************
* @file : main.c
* @brief : Reactive Light Controller
******************************************************************************
*/
/* USER CODE END Header */
#include "main.h"
#include <string.h>
#include <stdio.h>
TIM_HandleTypeDef htim2;
UART_HandleTypeDef huart2;
uint16_t brightness = 0;
int8_t direction = 1;
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_TIM2_Init(void);
static void MX_USART2_UART_Init(void);
void run_breathing(void);
void run_breathing(void)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, brightness);
brightness += direction * 5;
if (brightness >= 999) { brightness = 999; direction = -1; }
else if (brightness == 0 || brightness > 60000) { brightness = 0; direction = 1; }
HAL_Delay(10);
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_TIM2_Init();
MX_USART2_UART_Init();
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
while (1) { run_breathing(); }
}
/* SystemClock_Config, MX_TIM2_Init, MX_USART2_UART_Init, MX_GPIO_Init bodies as generated */
Your LED is alive and breathing. Next up, you will add UART communication so the board can report its state to your PC and accept commands from a serial terminal.
Add UART Communication
Your LED is breathing beautifully, but right now you have no way to talk to your board from your computer. UART serial communication lets you send text messages between the STM32 and a terminal program on your PC.
In this step, you'll build a transmit helper function, set up interrupt-driven receive to accept remote commands, and verify bidirectional communication using PuTTY. The same USB cable that programs your board also carries serial data through the ST-LINK Virtual COM Port.
In this step, get ready to:
- Implement a UART transmit helper and startup banner.
- Set up interrupt-driven UART receive with command processing.
- Connect PuTTY and verify two-way serial communication.
Implement a UART transmit helper and startup banner
Before you can send messages to your PC, you need a few supporting pieces. A mode enum will let you track which light pattern is active, and a helper function will simplify sending strings over UART.
- In main.c, find the existing variable declarations (uint16_t brightness = 0; and int8_t direction = 1;).
- Add the following mode enum and new variables directly above those two lines:
typedef enum { MODE_BREATHING = 0, MODE_FAST_BLINK, MODE_SOLID, MODE_COUNT } LightMode;
volatile LightMode current_mode = MODE_BREATHING;
volatile uint8_t mode_changed = 0;
volatile uint32_t last_button_time = 0;
uint8_t rx_byte = 0;
What does this code do?
The LightMode enum defines three light modes your controller will support (plus a MODE_COUNT sentinel for cycling through them).
The volatile keyword tells the compiler these variables may be changed by interrupt handlers at any time, so it must not optimize away reads. rx_byte is a single-byte buffer where the UART receive interrupt places each incoming character.
- Now add a tx_buffer variable directly below the existing int8_t direction = 1; line:
char tx_buffer[100];
- In the function prototypes section (below the variables), add this new prototype alongside the existing ones:
void uart_transmit_string(const char *str);
- Now add the uart_transmit_string() function implementation above the existing run_breathing() function:
void uart_transmit_string(const char *str)
{
HAL_UART_Transmit(&huart2, (uint8_t *)str, strlen(str), HAL_MAX_DELAY);
}
What does this function do?
HAL_UART_Transmit() sends raw bytes over UART2, which is wired to the ST-LINK Virtual COM Port on your Nucleo board. HAL_MAX_DELAY means the function blocks until all bytes are sent.
By wrapping it in a helper, you avoid repeating the cast and strlen() call every time you want to send a message.
- Inside main(), find the line HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);.
- Add the following startup banner code between that line and the while (1) loop:
uart_transmit_string("\r\n=== Reactive Light Controller ===\r\n");
uart_transmit_string("Commands: 1=Breathing, 2=Fast Blink, 3=Solid\r\n");
uart_transmit_string("Button: Cycle modes\r\n\r\n");
What does this code do?
These three calls print a welcome banner and command reference to the serial terminal as soon as the board boots. The \r\n sequences create proper line endings that PuTTY expects (carriage return + line feed).
- Save main.c.
You won't see a change on the LED yet. The banner will be visible once you connect PuTTY in Substep 3.
Set up interrupt-driven UART receive
Sending text to your PC is only half the story. To accept commands from the terminal, you need the MCU to listen for incoming bytes using an interrupt-driven receive callback.
- In main.c, add the HAL_UART_RxCpltCallback() function between uart_transmit_string() and run_breathing():
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2) {
switch (rx_byte) {
case '1': current_mode = MODE_BREATHING; mode_changed = 1; break;
case '2': current_mode = MODE_FAST_BLINK; mode_changed = 1; break;
case '3': current_mode = MODE_SOLID; mode_changed = 1; break;
default: break;
}
/* Re-arm the receive interrupt for the next byte */
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
}
}
What does this callback do?
The HAL library automatically calls HAL_UART_RxCpltCallback() every time a byte arrives on UART2. The switch statement checks which character was received and updates current_mode accordingly.
The final HAL_UART_Receive_IT() call re-arms the interrupt so the MCU is ready for the next byte. Without this re-arm, the callback would fire only once and then go silent.
- Now you need to arm the receive interrupt for the first time. In main(), find the line HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);.
- Add this line directly below it (before the banner messages):
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
Why arm before the banner?
Placing the receive-arm call before the banner ensures the MCU is already listening by the time any terminal connects. If you placed it after, a fast typist could send a command during the banner transmission and the byte would be lost.
- Save main.c.
- Build the project by clicking the hammer icon in STM32CubeIDE.
- Flash the firmware by clicking the green play button (Run).
The LED continues breathing as before. The UART code is running but you need a terminal to see the output.
Seeing build errors?
Check that #include <string.h> is at the top of your file. The strlen() call in uart_transmit_string() requires it.
If you see an undeclared identifier error for rx_byte or current_mode, make sure the variable declarations appear above the function that uses them.
help me fix build errors in my STM32 UART code
Connect PuTTY and verify serial communication
Your firmware is now transmitting a banner and listening for commands. You need a serial terminal on your PC to see the output and send characters back.
- First, find the COM port your board is using. Press the Windows key, type Device Manager, and press Enter.
- Expand Ports (COM & LPT).
- Look for STMicroelectronics STLink Virtual COM Port and note the COM number (for example, COM3).
What is a Virtual COM Port?
The ST-LINK debugger on your Nucleo board creates a virtual serial port over USB. This means USART2 data travels through the same USB cable you use for programming. No extra wiring needed.
- Open PuTTY. Press the Windows key, type PuTTY, and press Enter.
- Under Connection type, select Serial.
- In the Serial line field, enter your COM port (for example, COM3).
- In the Speed field, enter 115200.
- Click Open.
You should see the startup banner appear in the PuTTY window:
=== Reactive Light Controller ===
Commands: 1=Breathing, 2=Fast Blink, 3=Solid
Button: Cycle modes
- Type 2 on your keyboard (no Enter needed).
- Type 3, then 1.
The LED won't change behavior yet because the main loop still only calls run_breathing(). You'll wire up the mode state machine in the next step. For now, you've confirmed the board receives your keystrokes and updates current_mode internally.
Seeing garbled text or a blank screen?
Garbled characters usually mean the baud rate doesn't match. Double-check that PuTTY's Speed is set to 115200.
A completely blank screen often means the wrong COM port. Go back to Device Manager and verify the port number under Ports (COM & LPT).
If you don't see the banner, try pressing the black RESET button on the Nucleo board while PuTTY is open. The banner only prints once at boot.
help me troubleshoot PuTTY serial connection to my STM32 board
✔️ Awesome, I've got everything!
Great. Double check you've saved main.c and that your build has zero errors.
ⓧ I'd like to double check the full code
Here's the full main.c as it should look at this point:
/* USER CODE BEGIN Header */
/**
******************************************************************************
* @file : main.c
* @brief : Reactive Light Controller
******************************************************************************
*/
/* USER CODE END Header */
#include "main.h"
#include <string.h>
#include <stdio.h>
TIM_HandleTypeDef htim2;
UART_HandleTypeDef huart2;
typedef enum { MODE_BREATHING = 0, MODE_FAST_BLINK, MODE_SOLID, MODE_COUNT } LightMode;
volatile LightMode current_mode = MODE_BREATHING;
volatile uint8_t mode_changed = 0;
volatile uint32_t last_button_time = 0;
uint8_t rx_byte = 0;
uint16_t brightness = 0;
int8_t direction = 1;
char tx_buffer[100];
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_TIM2_Init(void);
static void MX_USART2_UART_Init(void);
void uart_transmit_string(const char *str);
void run_breathing(void);
void uart_transmit_string(const char *str)
{
HAL_UART_Transmit(&huart2, (uint8_t *)str, strlen(str), HAL_MAX_DELAY);
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2) {
switch (rx_byte) {
case '1': current_mode = MODE_BREATHING; mode_changed = 1; break;
case '2': current_mode = MODE_FAST_BLINK; mode_changed = 1; break;
case '3': current_mode = MODE_SOLID; mode_changed = 1; break;
default: break;
}
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
}
}
void run_breathing(void)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, brightness);
brightness += direction * 5;
if (brightness >= 999) { brightness = 999; direction = -1; }
else if (brightness == 0 || brightness > 60000) { brightness = 0; direction = 1; }
HAL_Delay(10);
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_TIM2_Init();
MX_USART2_UART_Init();
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
uart_transmit_string("\r\n=== Reactive Light Controller ===\r\n");
uart_transmit_string("Commands: 1=Breathing, 2=Fast Blink, 3=Solid\r\n");
uart_transmit_string("Button: Cycle modes\r\n\r\n");
while (1) { run_breathing(); }
}
/* SystemClock_Config, MX_TIM2_Init, MX_USART2_UART_Init, MX_GPIO_Init bodies as generated */
Your board can now talk to your PC and listen for commands. Next up, you'll wire the mode variable into a state machine so pressing 2 or 3 in PuTTY actually changes what the LED does.
Add Button Interrupt for Mode Switching
Your board can already receive commands from PuTTY, but a real embedded device needs physical controls too. The user button on your Nucleo board (B1 on pin PC13) is wired to an EXTI interrupt line that CubeMX already configured for you.
In this step, you will write the interrupt callback that fires when the button is pressed, add the remaining light mode functions, and wire everything together into a state machine. By the end, pressing the button or sending a UART command both cycle through three distinct LED behaviours.
In this step, get ready to:
- Implement a debounced button interrupt handler.
- Add fast blink and solid mode functions.
- Build a mode state machine in the main loop that responds to both button and UART inputs.
Implement the button interrupt callback
Mechanical buttons bounce when pressed, producing multiple rapid edges that look like many presses to the microcontroller. Software debouncing ignores any press that arrives within a short time window of the previous one.
- In main.c, find the HAL_UART_RxCpltCallback function.
- Add the following HAL_GPIO_EXTI_Callback function directly above it:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_13)
{
/* Read the current tick count for debounce comparison */
uint32_t now = HAL_GetTick();
if (now - last_button_time > 200)
{
/* Valid press - update timestamp and advance mode */
last_button_time = now;
current_mode = (LightMode)((current_mode + 1) % MODE_COUNT);
mode_changed = 1;
}
}
}
What does this code do?
The HAL library calls this function automatically whenever an EXTI interrupt fires. CubeMX generated the low-level IRQ handler in stm32f4xx_it.c that routes to this callback.
- GPIO_PIN_13 confirms the interrupt came from the user button, not some other pin.
- HAL_GetTick() returns milliseconds since boot. If fewer than 200 ms passed since the last press, the callback exits without doing anything.
- The modulo (% MODE_COUNT) wraps back to mode 0 after reaching the last mode.
- Save main.c.
You will not see a behaviour change yet because the main loop still only calls run_breathing(). The next chunk adds the remaining mode functions.
Seeing a compile error about redefinition?
Make sure you placed the function above HAL_UART_RxCpltCallback and not inside another function's braces. Also confirm the function signature matches exactly: void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin).
Help me fix a compile error in my EXTI callback.
Add the fast blink and solid mode functions
Each light mode needs its own function that the main loop can dispatch to. You already have run_breathing(). Now you need two more.
- In main.c, scroll to the end of run_breathing().
- Add the following two functions directly below it:
void run_fast_blink(void)
{
/* Set LED to full brightness */
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 999);
HAL_Delay(100);
/* Turn LED off */
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0);
HAL_Delay(100);
}
void run_solid(void)
{
/* Hold LED at maximum duty cycle */
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 999);
HAL_Delay(50);
}
What does this code do?
- run_fast_blink() toggles the PWM duty cycle between 999 (full on) and 0 (off) with 100 ms between each state, creating a rapid flashing effect.
- run_solid() holds the duty cycle at maximum (999) so the LED stays continuously lit. The 50 ms delay prevents the loop from spinning at full speed.
- Also add the forward declarations for these functions near the top of the file, alongside the existing run_breathing declaration.
- Find this line:
void run_breathing(void);
- Add the two new declarations immediately below it so the section looks like this:
void run_breathing(void);
void run_fast_blink(void);
void run_solid(void);
- Save main.c.
You still will not see a mode switch because the main loop does not call these functions yet. The next chunk wires everything together.
Implicit declaration warning?
If you see a warning about implicit function declaration, double-check that void run_fast_blink(void); and void run_solid(void); appear before main() in the file.
Help me fix implicit declaration warnings in my STM32 project.
Build the mode state machine in the main loop
A state machine checks which mode is active and dispatches to the matching function each iteration. It also resets brightness when switching modes so each pattern starts cleanly.
- In main.c, find the current while (1) loop inside main():
while (1) { run_breathing(); }
- Replace the entire while (1) block with the following state machine:
while (1)
{
/* Check if either the button or UART changed the mode */
if (mode_changed)
{
mode_changed = 0;
brightness = 0;
direction = 1;
/* Build and send mode-change notification over UART */
const char *mode_names[] = {"Breathing", "Fast Blink", "Solid"};
snprintf(tx_buffer, sizeof(tx_buffer), "[Mode changed] %s\r\n", mode_names[current_mode]);
uart_transmit_string(tx_buffer);
}
/* Dispatch to the active mode's function */
switch (current_mode)
{
case MODE_BREATHING: run_breathing(); break;
case MODE_FAST_BLINK: run_fast_blink(); break;
case MODE_SOLID: run_solid(); break;
default: break;
}
}
What does this code do?
- The mode_changed flag is set by both the button callback and the UART callback. The main loop checks it once per iteration.
- When a mode change is detected, brightness and direction reset to zero/one so the new pattern starts from a clean state.
- snprintf formats a human-readable mode name into tx_buffer, which gets sent to PuTTY over UART.
- The switch statement calls the correct mode function each loop iteration based on current_mode.
- Save main.c.
- Build the project by clicking the hammer icon in the toolbar (or press Ctrl+B).
- Verify the build completes with 0 errors in the Console panel.
Build fails with undeclared identifier?
If snprintf is undeclared, confirm that #include <stdio.h> is at the top of your file. If mode_names or tx_buffer is undeclared, make sure you placed the new while block inside main() (not outside it).
Help me fix build errors in my state machine code.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.c and the build shows 0 errors before flashing.
ⓧ I'd like to double check the full code
Here is the complete main.c at this point. Compare yours against it:
/* USER CODE BEGIN Header */
/**
******************************************************************************
* @file : main.c
* @brief : Reactive Light Controller
******************************************************************************
*/
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include <string.h>
#include <stdio.h>
/* Private variables ---------------------------------------------------------*/
TIM_HandleTypeDef htim2;
UART_HandleTypeDef huart2;
/* USER CODE BEGIN PV */
typedef enum {
MODE_BREATHING = 0,
MODE_FAST_BLINK,
MODE_SOLID,
MODE_COUNT
} LightMode;
volatile LightMode current_mode = MODE_BREATHING;
volatile uint8_t mode_changed = 0;
volatile uint32_t last_button_time = 0;
uint8_t rx_byte = 0;
uint16_t brightness = 0;
int8_t direction = 1;
char tx_buffer[100];
/* USER CODE END PV */
/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_TIM2_Init(void);
static void MX_USART2_UART_Init(void);
/* USER CODE BEGIN PFP */
void uart_transmit_string(const char *str);
void run_breathing(void);
void run_fast_blink(void);
void run_solid(void);
/* USER CODE END PFP */
/* USER CODE BEGIN 0 */
void uart_transmit_string(const char *str)
{
HAL_UART_Transmit(&huart2, (uint8_t *)str, strlen(str), HAL_MAX_DELAY);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_13)
{
uint32_t now = HAL_GetTick();
if (now - last_button_time > 200)
{
last_button_time = now;
current_mode = (LightMode)((current_mode + 1) % MODE_COUNT);
mode_changed = 1;
}
}
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2)
{
switch (rx_byte)
{
case '1': current_mode = MODE_BREATHING; mode_changed = 1; break;
case '2': current_mode = MODE_FAST_BLINK; mode_changed = 1; break;
case '3': current_mode = MODE_SOLID; mode_changed = 1; break;
default: break;
}
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
}
}
void run_breathing(void)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, brightness);
brightness += direction * 5;
if (brightness >= 999) { brightness = 999; direction = -1; }
else if (brightness == 0 || brightness > 60000) { brightness = 0; direction = 1; }
HAL_Delay(10);
}
void run_fast_blink(void)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 999);
HAL_Delay(100);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0);
HAL_Delay(100);
}
void run_solid(void)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 999);
HAL_Delay(50);
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_TIM2_Init();
MX_USART2_UART_Init();
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
uart_transmit_string("\r\n=== Reactive Light Controller ===\r\n");
uart_transmit_string("Commands: 1=Breathing, 2=Fast Blink, 3=Solid\r\n");
uart_transmit_string("Button: Cycle modes\r\n\r\n");
while (1)
{
if (mode_changed)
{
mode_changed = 0;
brightness = 0;
direction = 1;
const char *mode_names[] = {"Breathing", "Fast Blink", "Solid"};
snprintf(tx_buffer, sizeof(tx_buffer), "[Mode changed] %s\r\n", mode_names[current_mode]);
uart_transmit_string(tx_buffer);
}
switch (current_mode)
{
case MODE_BREATHING: run_breathing(); break;
case MODE_FAST_BLINK: run_fast_blink(); break;
case MODE_SOLID: run_solid(); break;
default: break;
}
}
}
void SystemClock_Config(void)
{
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
__HAL_RCC_PWR_CLK_ENABLE();
__HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE2);
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
RCC_OscInitStruct.PLL.PLLM = 16;
RCC_OscInitStruct.PLL.PLLN = 336;
RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV4;
RCC_OscInitStruct.PLL.PLLQ = 7;
HAL_RCC_OscConfig(&RCC_OscInitStruct);
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2);
}
static void MX_TIM2_Init(void)
{
TIM_OC_InitTypeDef sConfigOC = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 83;
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 999;
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
HAL_TIM_PWM_Init(&htim2);
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 0;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
}
static void MX_USART2_UART_Init(void)
{
huart2.Instance = USART2;
huart2.Init.BaudRate = 115200;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;
huart2.Init.Mode = UART_MODE_TX_RX;
huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart2.Init.OverSampling = UART_OVERSAMPLING_16;
HAL_UART_Init(&huart2);
}
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
Flash and test all three modes
- Flash the firmware to your board by clicking the green play button in the toolbar (or press F11).
- Switch back to the PuTTY window connected at 115200 baud.
- Press the blue user button (B1) on the board.
- Confirm the LED changes from a smooth breathing fade to a fast blink.
- Press the button again and confirm the LED switches to solid on.
- Press once more. The LED returns to the breathing pattern.
- In PuTTY, type 2 and confirm the LED switches to fast blink without touching the button.
- Verify PuTTY displays [Mode changed] Fast Blink for both button-triggered and UART-triggered changes.
What should I see in PuTTY?
Every time the mode changes (via button or UART), a new line like [Mode changed] Breathing appears. Both input methods set the same mode_changed flag, so the UART notification logic works identically for both.
Button press does nothing?
Confirm the blue B1 button is the one you are pressing (not the black reset button). The reset button restarts the firmware entirely.
If the LED changes but PuTTY shows nothing, check that PuTTY is still connected. Flashing new firmware can reset the COM port connection.
Help me debug why my button interrupt is not firing on my Nucleo board.
Secret mission
Add Disco Mode
Your light controller has three modes, but what if it could cycle through all of them automatically? In this secret mission, you will add a fourth mode that creates a disco effect by rotating through all patterns with configurable speed, controlled entirely via UART.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and the NUCLEO-F401RE board, so there are no ongoing cloud costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- STM32CubeIDE project workspace (local files on your computer).
- STM32CubeIDE 2.1.0 (installed IDE).
- STM32CubeMX (installed configuration tool).
- PuTTY (installed serial terminal, Windows only).
- Firmware flashed on the NUCLEO-F401RE board.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with the firmware further.
- Your STM32CubeIDE project stays on disk, ready for editing.
- The firmware continues running on your NUCLEO-F401RE board whenever it is powered via USB.
- STM32CubeIDE, STM32CubeMX, and PuTTY remain installed for future embedded projects.
Pause - I'll come back to this later
Disconnect the board to free up a USB port. Your project files stay on disk for when you return.
- Unplug the USB Micro-B cable from the NUCLEO-F401RE board.
- Close STM32CubeIDE and PuTTY if they are still open.
- Your project files and installed tools remain on your computer for next time.
Delete - I don't want to use this again
Remove all project files and optionally uninstall the development tools.
- Unplug the USB Micro-B cable from the NUCLEO-F401RE board. The firmware stays on the board but does nothing without power.
- Delete your STM32CubeIDE project workspace folder (the folder you chose in STM32CubeMX Project Manager).
- To uninstall STM32CubeIDE on Windows, open Settings, select Apps, find STM32CubeIDE in the list, and click Uninstall. On macOS, drag the STM32CubeIDE app from your Applications folder to Trash.
- To uninstall STM32CubeMX on Windows, open Settings, select Apps, find STM32CubeMX in the list, and click Uninstall. On macOS, drag the STM32CubeMX app from your Applications folder to Trash.
- To uninstall PuTTY on Windows, open Settings, select Apps, find PuTTY in the list, and click Uninstall.
Nice Work!
Nice Work!
Nice work. You just built a complete reactive light controller running on real embedded hardware from scratch.
You've learned how to:
- Generated PWM signals using a hardware timer to smoothly control LED brightness on an STM32 microcontroller.
- Established bidirectional UART communication between the Nucleo board and a PC terminal for remote control.
- Implemented hardware interrupts with software debouncing and a state machine pattern for real-time mode switching.
- Built a non-blocking disco mode that cycles through all light patterns with configurable speed controlled via UART commands.
Ready to quiz yourself?