Part III — Hardware·Chapter 10
⬡ Part III Capstone

PWM, Motor Drive
& Encoder Capstone

Everything you have learned — Embassy tasks, shared state, GPIO type-state, soft protocols, I2C — converges here. A DC motor driven by an L298N H-bridge, its speed measured by a quadrature encoder, the angle displayed on the TM1637, and PCA9685 servo position all coordinated by three concurrent Embassy tasks. This is your first real embedded system.
§ 10.1
PWM Slices on the RP2350

Pulse Width Modulation is the fundamental technique for controlling power to actuators: motors, LEDs, heaters, speakers. You cannot vary voltage on a microcontroller output — it is either 3.3V or 0V — but you can vary the fraction of time it spends high. A motor receiving a signal that is high 75% of the time receives 75% of its theoretical power. The motor's mechanical inertia smooths the switching into effective analogue control.

The RP2350 has 12 PWM slices (slice 0–11). Each slice has two channels: A and B. Each channel maps to a specific GPIO pin — you cannot choose freely. GPIO8 maps to PWM slice 4 channel A, GPIO9 to slice 4B, and GPIO10 to slice 5A. For the L298N Enable pin (ENA on GPIO10) we want slice 5A.

RP2350 PWM SLICE ARCHITECTURE
────────────────────────────────────────────────────────────

  Each slice has:
    ┌──────────────────────────────────────────────┐
    │  16-bit counter  (counts 0 → TOP, then wraps) │
    │  TOP register    (sets period = frequency)     │
    │  Compare A / B   (sets duty cycle per channel) │
    │  Clock divider   (slows counter clock)         │
    └──────────────────────────────────────────────┘

  Frequency formula:
    f_pwm = f_sys / (divider × (TOP + 1))
    f_sys = 150 MHz (RP2350 default)

  For 20 kHz PWM (above human hearing, below FET switching loss):
    divider = 1, TOP = 7499
    f = 150_000_000 / (1 × 7500) = 20_000 Hz ✓

  Duty cycle:
    compare_value = (duty_percent / 100.0) × (TOP + 1)
    75% duty → compare = 0.75 × 7500 = 5625

  GPIO → Slice map (partial):
    GPIO8  → Slice 4 Channel A
    GPIO9  → Slice 4 Channel B
    GPIO10 → Slice 5 Channel A  ← ENA pin (motor speed)
    GPIO12 → Slice 6 Channel A
    GPIO14 → Slice 7 Channel A
PWM slice architecture on the RP2350. The 16-bit counter wraps at TOP continuously; output is high while counter < compare value.

Embassy exposes PWM through the embassy_rp::pwm module. The Pwm struct takes ownership of the slice — consistent with the peripheral singleton model you studied in Chapter 6. You configure a Config with top, compare values, and divider, then call set_config to update live.

src/main.rs — PWM initialisation
use embassy_rp::pwm::{Config as PwmConfig, Pwm};

// Configure PWM slice 5 for 20 kHz
let mut pwm_cfg = PwmConfig::default();
pwm_cfg.top = 7499;          // sets period → 20 kHz at 150 MHz sysclk
pwm_cfg.compare_a = 0;        // start at 0% duty (motor stopped)
pwm_cfg.divider = 1u8.into(); // no division

let pwm = Pwm::new_output_a(
    p.PWM_SLICE5,  // hardware singleton — compile error if used twice
    p.PIN_10,      // GPIO10 = ENA on L298N
    pwm_cfg.clone(),
);

// To change duty cycle at runtime:
fn set_duty(pwm: &mut Pwm<'_, PWM_SLICE5>, percent: f32) {
    let mut cfg = PwmConfig::default();
    cfg.top = 7499;
    cfg.compare_a = (percent / 100.0 * 7500.0) as u16;
    pwm.set_config(&cfg);
}
§ 10.2
The L298N H-Bridge

A microcontroller GPIO can source perhaps 12mA at 3.3V. A DC motor wants hundreds of milliamps at 12V. The L298N is an H-bridge — four transistors arranged in an H pattern — that switches motor current from an external supply (up to 46V, 2A) under control of low-power logic signals. It is named after the four transistors forming the letter H with the motor as the crossbar.

L298N H-BRIDGE WIRING
──────────────────────────────────────────────────

  L298N Module Pins        RP2350 GPIO
  ─────────────────────    ──────────────
  IN1                  ←── GPIO8
  IN2                  ←── GPIO9
  ENA                  ←── GPIO10 (PWM)
  GND                  ←── GND
  +5V (logic)          ←── 3.3V (works fine)
  12V (motor power)    ←── External 12V supply

  Direction truth table:
  ┌──────┬──────┬───────────────────────┐
  │ IN1  │ IN2  │ Motor                 │
  ├──────┼──────┼───────────────────────┤
  │ HIGH │ LOW  │ Forward               │
  │ LOW  │ HIGH │ Reverse               │
  │ LOW  │ LOW  │ Coast (free spin)     │
  │ HIGH │ HIGH │ Brake (active short)  │
  └──────┴──────┴───────────────────────┘

  ENA = PWM signal → controls speed (0-100% duty)
  ENA = LOW → motor always off regardless of IN1/IN2
L298N wiring and truth table. IN1/IN2 set direction; ENA PWM duty sets speed.
⚠ Warning — Shoot-Through

Never set IN1 and IN2 both HIGH for extended periods. This creates a shoot-through condition: both high-side and low-side transistors conduct simultaneously, creating a short across your motor supply. The L298N has internal protection but it generates significant heat. Always coast or brake by setting one or both LOW.

motor control helpers
use embassy_rp::gpio::{Level, Output};

struct Motor<'d> {
    in1: Output<'d>,
    in2: Output<'d>,
}

impl<'d> Motor<'d> {
    fn new(in1: Output<'d>, in2: Output<'d>) -> Self {
        Self { in1, in2 }
    }

    fn forward(&mut self) {
        self.in1.set_high();
        self.in2.set_low();
    }

    fn reverse(&mut self) {
        self.in1.set_low();
        self.in2.set_high();
    }

    fn coast(&mut self) {
        self.in1.set_low();
        self.in2.set_low();
    }

    fn brake(&mut self) {
        self.in1.set_high();
        self.in2.set_high();
    }
}
§ 10.3
Quadrature Encoder Decoding

Your rotary encoder outputs two signals: CLK (GPIO6) and DT (GPIO7). These are not synchronised to any clock — they are purely mechanical contacts that open and close as the shaft rotates. The two channels are offset by 90 degrees (one quarter period) — hence quadrature. This phase relationship tells you both speed and direction.

QUADRATURE ENCODER SIGNAL DIAGRAM
──────────────────────────────────────────────────────────

  Clockwise rotation:
                     ↓ CLK falls first
  CLK: ‾‾‾‾|____|‾‾‾‾|____|‾‾‾‾
  DT:  ‾‾|____|‾‾‾‾|____|‾‾‾‾|‾‾
               ↑ DT falls AFTER CLK

  When CLK falls (↓): read DT
    DT = HIGH  → clockwise   (+1)
    DT = LOW   → counter-CW  (-1)

  Counter-clockwise rotation:
  CLK: ‾‾‾‾|____|‾‾‾‾|____|‾‾‾‾
  DT:  ‾‾‾‾‾‾|____|‾‾‾‾|____|‾‾
               ↑ DT falls BEFORE CLK

  When CLK falls (↓): read DT
    DT = LOW   → counter-clockwise (-1)

  Position = cumulative sum of steps
  Speed = steps per unit time
Quadrature encoding. The phase lead/lag of DT relative to CLK reveals direction unambiguously.

In Embassy, we use ExtiInput to receive async interrupts on GPIO6. Every falling edge on CLK wakes our encoder task which immediately samples GPIO7 for direction. The position is stored in a Mutex<CriticalSectionRawMutex, i32> so multiple tasks can safely read it.

encoder task
use embassy_rp::gpio::{Input, Pull};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;

// Global shared position counter
static POSITION: Mutex<CriticalSectionRawMutex, i32> =
    Mutex::new(0);

#[embassy_executor::task]
async fn encoder_task(
    mut clk: Input<'static>,
    dt: Input<'static>,
) {
    loop {
        // Sleep until CLK falls — zero CPU usage while waiting
        clk.wait_for_falling_edge().await;

        // Sample DT immediately — phase window is microseconds
        let direction = if dt.is_high() { 1i32 } else { -1i32 };

        let mut pos = POSITION.lock().await;
        *pos += direction;

        // Optional: log every 20 steps (~one revolution)
        if *pos % 20 == 0 {
            defmt::info!("Encoder position: {}", *pos);
        }
    }
}
§ 10.4
Shared State Between Embassy Tasks

Three tasks need to share data: the encoder task writes position, the display task reads position to show on TM1637, and the motor controller task reads position for closed-loop control. In a threaded OS you might reach for a global variable protected by a mutex — and that is exactly what we do, but Embassy's Mutex is async-aware and works with the single-core cooperative scheduler.

Embassy Synchronisation Primitives

Which primitive for which job?

Mutex<CriticalSectionRawMutex, T> — for shared mutable state accessed briefly. CriticalSection disables interrupts during the lock. Use for primitive types (i32, f32, structs) that fit in a few words. Our position counter is ideal.

Channel<M, T, N> — for passing messages between tasks. Producer sends, consumer receives. MPMC (multi-producer multi-consumer) or SPSC. Use when one task produces events another consumes — button presses, sensor readings.

Signal<M, T> — like a channel of capacity 1. Latest value wins, older values are discarded. Use for continuously-updated values where only the most recent matters — sensor readings, setpoints.

Avoid: RefCell across await points, unsafe static muts, anything that blocks (like std::sync::Mutex).

complete shared state pattern
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
use embassy_sync::signal::Signal;

// Position: written by encoder, read by display + controller
static POSITION: Mutex<CriticalSectionRawMutex, i32> =
    Mutex::new(0);

// Speed setpoint: written by controller, read by PWM driver
static SPEED_SETPOINT: Signal<CriticalSectionRawMutex, f32> =
    Signal::new();

// Display task — reads position every 100ms, shows on TM1637
#[embassy_executor::task]
async fn display_task(/* TM1637 pins */) {
    loop {
        Timer::after_millis(100).await;
        let pos = *POSITION.lock().await;
        // display pos on TM1637 (Chapter 8 driver)
        tm1637_show(pos.abs() as u16).await;
    }
}

// Motor controller — simple proportional controller
#[embassy_executor::task]
async fn motor_task(/* motor + pwm handles */) {
    let target = 200i32; // target encoder position
    loop {
        Timer::after_millis(50).await;
        let pos = *POSITION.lock().await;
        let error = target - pos;
        // P-controller: gain 0.5, clamp 0-100%
        let duty = (error as f32 * 0.5).clamp(0.0, 100.0);
        SPEED_SETPOINT.signal(duty);
    }
}
§ 10.5
The Complete Capstone System

This is the complete three-task capstone. Copy this in full into your project's src/main.rs. You will need to add the dependencies to Cargo.toml and the Embassy executor macro. The system runs four concurrent Embassy tasks: the encoder interrupt handler, the display refresher, the motor P-controller, and main's async block which handles the startup sequence.

Cargo.toml — dependencies for capstone
[package]
name = "motor-capstone"
version = "0.1.0"
edition = "2021"

[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
embassy-rp       = { version = "0.2", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl"] }
embassy-time     = { version = "0.3", features = ["defmt"] }
embassy-sync     = { version = "0.6", features = ["defmt"] }
cortex-m         = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt      = "0.7"
defmt            = "0.3"
defmt-rtt        = "0.4"
panic-probe      = { version = "0.3", features = ["print-defmt"] }
static-cell      = "2"
portable-atomic  = { version = "1", features = ["critical-section"] }

[profile.release]
opt-level = 's'     # optimise for size on embedded
debug = 2           # keep debug info for probe-rs
lto = true          # link-time optimisation — smaller binary

[[bin]]
name = "motor-capstone"
test = false
bench = false
src/main.rs — complete capstone
#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::pwm::{Config as PwmConfig, Pwm};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
use embassy_sync::signal::Signal;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

// ─── Shared state ────────────────────────────────────────────
static POSITION: Mutex<CriticalSectionRawMutex, i32> = Mutex::new(0);
static SPEED_PCT: Signal<CriticalSectionRawMutex, f32> = Signal::new();

// ─── Encoder task ────────────────────────────────────────────
#[embassy_executor::task]
async fn encoder_task(
    mut clk: Input<'static>,
    dt: Input<'static>,
) {
    loop {
        clk.wait_for_falling_edge().await;
        let dir = if dt.is_high() { 1i32 } else { -1i32 };
        let mut pos = POSITION.lock().await;
        *pos += dir;
        defmt::trace!("enc pos={}", *pos);
    }
}

// ─── Display task (TM1637 simplified) ────────────────────────
#[embassy_executor::task]
async fn display_task() {
    loop {
        Timer::after_millis(100).await;
        let pos = *POSITION.lock().await;
        // In a real build: call your Chapter 8 TM1637 driver here
        defmt::info!("DISPLAY pos={}", pos);
    }
}

// ─── Motor controller task (P-controller) ────────────────────
#[embassy_executor::task]
async fn motor_task() {
    // This task only signals — actual PWM write happens in main loop
    // to avoid passing the Pwm handle across task boundaries
    let target = 120i32; // target encoder steps from start
    loop {
        Timer::after_millis(50).await;
        let pos = *POSITION.lock().await;
        let error = (target - pos) as f32;
        // Proportional gain = 0.8; saturate to [0, 100]
        let duty = (error * 0.8).abs().min(100.0);
        SPEED_PCT.signal(duty);
    }
}

// ─── Entry point ─────────────────────────────────────────────
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());

    defmt::info!("Motor capstone starting — SprintTZ NOC demo");

    // Motor direction pins
    let in1 = Output::new(p.PIN_8, Level::Low);
    let in2 = Output::new(p.PIN_9, Level::Low);
    let mut motor = Motor::new(in1, in2);

    // PWM for speed (ENA)
    let mut cfg = PwmConfig::default();
    cfg.top = 7499;
    cfg.compare_a = 0;
    let mut pwm = Pwm::new_output_a(p.PWM_SLICE5, p.PIN_10, cfg);

    // Encoder pins — pull-up, CLK on GPIO6, DT on GPIO7
    let clk = Input::new(p.PIN_6, Pull::Up);
    let dt  = Input::new(p.PIN_7, Pull::Up);

    // Spawn tasks
    spawner.spawn(encoder_task(clk, dt)).unwrap();
    spawner.spawn(display_task()).unwrap();
    spawner.spawn(motor_task()).unwrap();

    // Main loop: apply whatever speed the controller signals
    motor.forward();
    loop {
        let duty_pct = SPEED_PCT.wait().await;
        let compare = (duty_pct / 100.0 * 7500.0) as u16;
        let mut cfg2 = PwmConfig::default();
        cfg2.top = 7499;
        cfg2.compare_a = compare;
        pwm.set_config(&cfg2);

        if duty_pct < 2.0 {
            motor.coast();
            defmt::info!("Target reached — coasting");
        }
    }
}
✓ What You Built

Three Embassy tasks running concurrently on a single core. The encoder task sleeps in hardware until CLK falls — consuming zero cycles. The display task wakes every 100ms. The motor controller wakes every 50ms. The main loop only runs when a new speed signal arrives. Embassy's cooperative scheduler handles all of this with no RTOS, no threads, no heap allocation — just futures.

§ 10.6
Exercises
Exercise 10-A

Add a Velocity Estimate

The encoder task currently tracks position. Extend it to also compute velocity: the number of steps accumulated in the last 100ms. Store it in a second Signal<CriticalSectionRawMutex, i32> called VELOCITY. Display the velocity alongside position on the TM1637 by alternating every 500ms.

Exercise 10-B

PD Controller

The current motor task is a P-controller (proportional only). Add a derivative term: measure the rate of change of error between controller cycles. D-term = (error - prev_error) / dt. Tune Kp and Kd so the motor reaches target with minimal overshoot. Log the error to RTT and plot it with probe-rs's defmt graph view.

Exercise 10-C

Sprint NOC Demo — Network Latency Visualiser

Replace the encoder position concept with a simulated "network latency" value (0–999ms). Drive the motor speed proportionally to simulated latency: low latency = motor slow, high latency = motor fast. Display the value on TM1637. This is the kind of physical dashboard Sprint's NOC team could use to feel network health viscerally rather than watching a Zabbix graph. Implement a ramp function that cycles simulated latency from 10→500→10ms over 10 seconds.