diff --git a/Cargo.lock b/Cargo.lock
index 409a64f4c9b41d59527cface04caadb14b896566..9673353166ecc56d903781cb65c0310fec1753f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -129,7 +129,7 @@ dependencies = [
 
 [[package]]
 name = "dc-motor-driver-hat"
-version = "0.1.0"
+version = "0.2.0"
 dependencies = [
  "cortex-m",
  "cortex-m-rt",
diff --git a/README.org b/README.org
index 9f5f9c04f3409595df5626cde1812749908a3b44..540ddfab992559b05f05f55b92dd3f57a592bf1c 100644
--- a/README.org
+++ b/README.org
@@ -57,9 +57,9 @@ Multibyte values are exchanged in little endian format.
 
 - The I²C address
 
-** 0x10 PWM frequency (R/W)
+** 0x10 [IMPLEMENTED] PWM frequency (R/W)
 
-- Frequency in kHz, from 1 to 100 (default: 1)
+- Frequency in Hz on 3 bytes, from 1 to 100_000 (default: 10_000)
 
 ** [IMPLEMENTED] 0x11 Max motor percentage (R/W)
 
diff --git a/controller/Cargo.toml b/controller/Cargo.toml
index b6d96c3598a7242aab5efcaefb268c91309773a5..5bcdbcd5e52696b7bcf68269c9d49ae71903e6c1 100644
--- a/controller/Cargo.toml
+++ b/controller/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "dc-motor-driver-hat"
-version = "0.1.0"  # Update in python/controller.py as well
+version = "0.2.0"  # Update in python/controller.py as well
 authors = ["Samuel Tardieu <sam@rfc1149.net>"]
 edition = "2021"
 
diff --git a/controller/python/controller.py b/controller/python/controller.py
index 83b9a35f2c9a0075e938b9f6a69281a5f294df31..3013c4dc3e29ee2fd809bbe27fad5edde66579d1 100644
--- a/controller/python/controller.py
+++ b/controller/python/controller.py
@@ -3,7 +3,7 @@ from numbers import Real
 from typing import Any, Optional
 
 # Major and minor version of required firmware
-_REQUIRED_FIRMWARE_VERSION = (0, 1)
+_REQUIRED_FIRMWARE_VERSION = (0, 2)
 
 
 class FirmwareVersionMismatch(Exception):
@@ -16,6 +16,7 @@ class Controller:
 
     FIRMWARE_VERSION = 0x08
     WHO_AM_I = 0x0F
+    PWM_FREQUENCY = 0x10
     MAX_MOTOR_PERCENTAGE = 0x11
     MOTOR_SHUTDOWN_TIMEOUT = 0x28
     MOTOR_SPEED = 0x30
@@ -146,3 +147,16 @@ class Controller:
                 f"is not compatible with this library version ({VERSION})"
             )
             raise FirmwareVersionMismatch(error)
+
+    def set_pwm_frequency(self, freq: int):
+        """Set the PWM frequency in Hz, between 1 and 100000."""
+        if freq < 1 or freq > 100000:
+            raise ValueError(f"PWM frequency is out of [1, 100000] range: {freq}")
+        self._write(
+            self.PWM_FREQUENCY, [freq & 0xFF, (freq >> 8) & 0xFF, (freq >> 16) & 0xFF]
+        )
+
+    def get_pwm_frequency(self):
+        """Return the PWM frequency in Hz."""
+        a, b, c = self._read(self.PWM_FREQUENCY, 3, "BBB")
+        return a | (b << 8) | (c << 16)
diff --git a/controller/src/logic.rs b/controller/src/logic.rs
index beeec9942c492f7b2c500c2ef3551a0d84c5028f..68df072e134dc6ec740fab4afeb83ce4275e6181 100644
--- a/controller/src/logic.rs
+++ b/controller/src/logic.rs
@@ -3,6 +3,7 @@ use crate::{
     tb6612fng::{Movement, Tb6612fng},
 };
 use core::sync::atomic::{AtomicU32, Ordering};
+use embassy_stm32::time::hz;
 use embassy_time::{Instant, Timer};
 use heapless::Deque;
 use i2c2_target::{I2C2, MESSAGE_SIZE};
@@ -133,6 +134,7 @@ fn i2c_callback(command: &[u8], response: &mut Deque<u8, MESSAGE_SIZE>) {
 
 const FIRMWARE_VERSION: u8 = 0x08;
 const WHO_AM_I: u8 = 0x0f;
+const PWM_FREQUENCY: u8 = 0x10;
 const MAX_MOTOR_PERCENTAGE: u8 = 0x11;
 const MOTOR_SHUTDOWN_TIMEOUT: u8 = 0x28;
 const MOTOR_SPEED: u8 = 0x30;
@@ -156,11 +158,26 @@ fn process_command(
         [WHO_AM_I, ..] => {
             response.push_back(0x57).unwrap();
         }
+        &[PWM_FREQUENCY, a, b, c] => {
+            let f = u32::from_le_bytes([a, b, c, 0]);
+            if (1..=100_000).contains(&f) {
+                state.motors.set_frequency(hz(f));
+            } else {
+                defmt::warn!("incorrect PWM frequency {}", f);
+                return false;
+            }
+        }
+        [PWM_FREQUENCY] => {
+            let freq = state.motors.get_frequency().0;
+            for &b in &freq.to_le_bytes()[..3] {
+                response.push_back(b).unwrap();
+            }
+        }
         &[MAX_MOTOR_PERCENTAGE, p] => {
             if (1..=100).contains(&p) {
                 state.max_motor_percentage = p;
             } else {
-                defmt::warn!("Incorrect max percentage {}", p);
+                defmt::warn!("incorrect max percentage {}", p);
                 return false;
             }
         }
diff --git a/controller/src/main.rs b/controller/src/main.rs
index d2315486d9b6fd3877535fd9a65c63b064f8f578..64852e2757bd80f191770ed61eea3f9641e43a8b 100644
--- a/controller/src/main.rs
+++ b/controller/src/main.rs
@@ -56,7 +56,7 @@ async fn main(spawner: Spawner) {
         p.PB6,
         p.PB8,
         p.TIM1,
-        khz(100),
+        khz(10),
     );
 
     let encoders = Encoders::new(p.PA0, p.PA1, p.PA6, p.PA7, p.TIM2, p.TIM3);
diff --git a/controller/src/tb6612fng.rs b/controller/src/tb6612fng.rs
index 13844848411317c1d603f923e8ca403257e7e1a1..883cb9a6d134a9d0a58f76801b6d0b59fe3b0011 100644
--- a/controller/src/tb6612fng.rs
+++ b/controller/src/tb6612fng.rs
@@ -18,6 +18,7 @@ pub struct Tb6612fng<'a> {
     b1: Output<'a>,
     b2: Output<'a>,
     standby: Output<'a>,
+    freq: Hertz,
 }
 
 #[derive(Clone, Copy)]
@@ -71,6 +72,7 @@ impl Tb6612fng<'_> {
         );
         pwm.enable(Channel::Ch3);
         pwm.enable(Channel::Ch4);
+
         Self {
             pwm,
             a1,
@@ -78,6 +80,7 @@ impl Tb6612fng<'_> {
             b1,
             b2,
             standby,
+            freq,
         }
     }
 
@@ -85,6 +88,16 @@ impl Tb6612fng<'_> {
         self.pwm.get_max_duty()
     }
 
+    pub fn set_frequency(&mut self, freq: Hertz) {
+        self.freq = freq;
+        self.move_both(Movement::Stop, Movement::Stop);
+        self.pwm.set_frequency(freq);
+    }
+
+    pub fn get_frequency(&self) -> Hertz {
+        self.freq
+    }
+
     pub fn standby_enter(&mut self) {
         self.standby.set_low();
     }