diff --git a/.cargo/config.toml b/.cargo/config.toml
index 971c54803626c62bbb93ae6a47d043394d8c3dc9..2ac7976e771a56c2d3b21655589757b228f705a3 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -6,8 +6,5 @@ rustflags = [
   "-C", "link-arg=-Tdefmt.x",
 ]
 
-[build]
-target = "thumbv7m-none-eabi"
-
 [env]
 DEFMT_LOG = "info"
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5d5bc5c7f2664596f31b37bcd99dc574630fea85..42020fd8f8b6d879f7945558c69978c3756fd5c8 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -15,15 +15,15 @@ stages:
       - .rustup
       - target/debug/deps
       - target/debug/build
-      - target/thumbv7m-none-eabi/release/deps
-      - target/thumbv7m-none-eabi/release/build
+      - target/thumbv7m-none-eabi/production/deps
+      - target/thumbv7m-none-eabi/production/build
     policy: pull-push
 
 variables:
   RUSTUP_HOME: ${CI_PROJECT_DIR}/.rustup
   CARGO_HOME: ${CI_PROJECT_DIR}/.cargo
 
-rust-lint:
+lint:rust:
   image: rust
   stage: check
   script:
@@ -31,50 +31,79 @@ rust-lint:
   - rustup component add rustfmt clippy
   - rustup target install thumbv7m-none-eabi
   - cargo fmt --all --check
-  - cargo clippy --all
+  - cargo clippy --target thumbv7m-none-eabi -p bootloader -p bootloader-params -p controller -p i2c2-target -p pid -p support
+  - cargo clippy -p firmware-updater
   <<: *rust-cache
 
-python-lint:
+lint:python:
   image: alpine
   stage: check
   script:
   - apk add py3-flake8
+  - flake8 bootloader/python
   - flake8 controller/python
 
-build-rust:
+build:rust:embedded:
   image: rust
   stage: build
+  needs: ["lint:rust"]
   script:
   - rustup default nightly
   - rustup target install thumbv7m-none-eabi
-  - cargo build --release
+  - (cd bootloader && cargo build --profile production)
+  - (cd controller && cargo build --profile production)
   <<: *rust-cache
   artifacts:
     paths:
-      - target/thumbv7m-none-eabi/release/dc-motor-driver-hat
+      - target/thumbv7m-none-eabi/production/bootloader
+      - target/thumbv7m-none-eabi/production/controller
 
-test-rust:
-  image: rust
-  stage: test
+build:rust:firmware-updater:
+  image: messense/rust-musl-cross:aarch64-musl
+  stage: build
+  needs: ["lint:rust"]
   script:
-  - rustup default nightly
-  - cd pid
-  - cargo test --target x86_64-unknown-linux-gnu
-  <<: *rust-cache
+  - rustup default stable
+  - rustup target add aarch64-unknown-linux-musl
+  - cd firmware-updater
+  - cargo build --target aarch64-unknown-linux-musl --profile production
+  artifacts:
+    paths:
+      - target/aarch64-unknown-linux-musl/production/firmware-updater
+
+test:rust:
+ image: rust
+ stage: test
+ script:
+ - rustup default stable
+ - cargo test -p pid
 
-test-python:
+test:python:
   image: python
   stage: test
+  needs: ["lint:python"]
   script:
   - cd controller/python
   - python -munittest controller_test.py
 
+test:firmware:
+  image: rust
+  stage: test
+  needs: ["build:rust:embedded", "build:rust:firmware-updater"]
+  script:
+  - rustup default stable
+  - cargo build -p firmware-updater
+  - cargo run -q -p firmware-updater -- check-file target/thumbv7m-none-eabi/production/controller
+
 deploy:
   image: alpine
   stage: deploy
   script:
   - mkdir firmware
-  - cp target/thumbv7m-none-eabi/release/dc-motor-driver-hat firmware/
+  - cp target/thumbv7m-none-eabi/production/bootloader firmware/
+  - cp target/thumbv7m-none-eabi/production/controller firmware/
+  - cp target/aarch64-unknown-linux-musl/production/firmware-updater firmware/
+  - cp bootloader/python/bootloader.py firmware/
   - cp controller/python/controller.py firmware/
   artifacts:
     paths:
diff --git a/Cargo.lock b/Cargo.lock
index 8ba760395868d168bf04ae1997cc12c492c8a34d..025a8d0cf7006e2c031ce977101da1c65c2a5d88 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,12 +2,91 @@
 # It is not intended for manual editing.
 version = 3
 
+[[package]]
+name = "addr2line"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "anstream"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
 
+[[package]]
+name = "backtrace"
+version = "0.3.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
 [[package]]
 name = "bare-metal"
 version = "0.2.5"
@@ -41,6 +120,32 @@ version = "2.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
 
+[[package]]
+name = "bootloader"
+version = "0.1.0"
+dependencies = [
+ "bootloader-params",
+ "build-support",
+ "cortex-m",
+ "cortex-m-rt",
+ "critical-section",
+ "defmt",
+ "defmt-rtt",
+ "embassy-executor",
+ "embassy-stm32",
+ "embassy-sync",
+ "embassy-time",
+ "futures",
+ "heapless",
+ "i2c2-target",
+ "panic-probe",
+ "support",
+]
+
+[[package]]
+name = "bootloader-params"
+version = "0.1.0"
+
 [[package]]
 name = "build-support"
 version = "0.1.0"
@@ -55,12 +160,126 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
+[[package]]
+name = "cc"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f"
+
 [[package]]
 name = "cfg-if"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "clap"
+version = "4.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f6b81fb3c84f5563d509c59b5a48d935f689e993afa90fe39047f05adef9142"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca6706fd5224857d9ac5eb9355f6683563cc0541c7cd9d014043b57cbec78ac"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.71",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
+
+[[package]]
+name = "color-eyre"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5"
+dependencies = [
+ "backtrace",
+ "color-spantrace",
+ "eyre",
+ "indenter",
+ "once_cell",
+ "owo-colors",
+ "tracing-error",
+]
+
+[[package]]
+name = "color-spantrace"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
+dependencies = [
+ "once_cell",
+ "owo-colors",
+ "tracing-core",
+ "tracing-error",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+
+[[package]]
+name = "console"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
+dependencies = [
+ "encode_unicode",
+ "lazy_static",
+ "libc",
+ "unicode-width",
+ "windows-sys",
+]
+
+[[package]]
+name = "controller"
+version = "0.3.1"
+dependencies = [
+ "bootloader-params",
+ "build-support",
+ "cortex-m",
+ "cortex-m-rt",
+ "critical-section",
+ "defmt",
+ "defmt-rtt",
+ "embassy-executor",
+ "embassy-stm32",
+ "embassy-sync",
+ "embassy-time",
+ "futures",
+ "heapless",
+ "i2c2-target",
+ "panic-probe",
+ "support",
+]
+
 [[package]]
 name = "cortex-m"
 version = "0.7.7"
@@ -135,27 +354,6 @@ dependencies = [
  "syn 2.0.71",
 ]
 
-[[package]]
-name = "dc-motor-driver-hat"
-version = "0.3.0"
-dependencies = [
- "build-support",
- "cortex-m",
- "cortex-m-rt",
- "critical-section",
- "defmt",
- "defmt-rtt",
- "embassy-executor",
- "embassy-stm32",
- "embassy-sync",
- "embassy-time",
- "futures",
- "heapless",
- "i2c2-target",
- "panic-probe",
- "support",
-]
-
 [[package]]
 name = "defmt"
 version = "0.3.8"
@@ -207,10 +405,16 @@ dependencies = [
  "litrs",
 ]
 
+[[package]]
+name = "elf"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b"
+
 [[package]]
 name = "embassy-embedded-hal"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "defmt",
  "embassy-futures",
@@ -227,7 +431,7 @@ dependencies = [
 [[package]]
 name = "embassy-executor"
 version = "0.5.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "cortex-m",
  "critical-section",
@@ -241,7 +445,7 @@ dependencies = [
 [[package]]
 name = "embassy-executor-macros"
 version = "0.4.1"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "darling",
  "proc-macro2",
@@ -252,12 +456,12 @@ dependencies = [
 [[package]]
 name = "embassy-futures"
 version = "0.1.1"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 
 [[package]]
 name = "embassy-hal-internal"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "cortex-m",
  "critical-section",
@@ -268,7 +472,7 @@ dependencies = [
 [[package]]
 name = "embassy-net-driver"
 version = "0.2.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "defmt",
 ]
@@ -276,7 +480,7 @@ dependencies = [
 [[package]]
 name = "embassy-stm32"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "bit_field",
  "bitflags 2.6.0",
@@ -320,7 +524,7 @@ dependencies = [
 [[package]]
 name = "embassy-sync"
 version = "0.6.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "cfg-if",
  "critical-section",
@@ -333,7 +537,7 @@ dependencies = [
 [[package]]
 name = "embassy-time"
 version = "0.3.1"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "cfg-if",
  "critical-section",
@@ -351,7 +555,7 @@ dependencies = [
 [[package]]
 name = "embassy-time-driver"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "document-features",
 ]
@@ -359,12 +563,12 @@ dependencies = [
 [[package]]
 name = "embassy-time-queue-driver"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 
 [[package]]
 name = "embassy-usb-driver"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "defmt",
 ]
@@ -372,7 +576,7 @@ dependencies = [
 [[package]]
 name = "embassy-usb-synopsys-otg"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy#e54c753537b4b12c3d2fd03ad8e8ba9eaaded06e"
+source = "git+https://github.com/embassy-rs/embassy#2537fc6f4fcbdaa0fcea45a37382d61f59cc5767"
 dependencies = [
  "critical-section",
  "embassy-sync",
@@ -457,6 +661,35 @@ dependencies = [
  "embedded-storage",
 ]
 
+[[package]]
+name = "encode_unicode"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "firmware-updater"
+version = "0.1.0"
+dependencies = [
+ "bootloader-params",
+ "clap",
+ "color-eyre",
+ "elf",
+ "indicatif",
+ "rppal",
+ "thiserror",
+]
+
 [[package]]
 name = "fnv"
 version = "1.0.7"
@@ -524,6 +757,12 @@ dependencies = [
  "pin-utils",
 ]
 
+[[package]]
+name = "gimli"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+
 [[package]]
 name = "hash32"
 version = "0.3.1"
@@ -543,6 +782,12 @@ dependencies = [
  "stable_deref_trait",
 ]
 
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
 [[package]]
 name = "i2c2-target"
 version = "0.1.0"
@@ -559,12 +804,73 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
 
+[[package]]
+name = "indenter"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
+
+[[package]]
+name = "indicatif"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3"
+dependencies = [
+ "console",
+ "instant",
+ "number_prefix",
+ "portable-atomic",
+ "unicode-width",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
 [[package]]
 name = "litrs"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
 
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+dependencies = [
+ "adler",
+]
+
 [[package]]
 name = "nb"
 version = "0.1.3"
@@ -589,6 +895,33 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "number_prefix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
+[[package]]
+name = "object"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "owo-colors"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
+
 [[package]]
 name = "panic-probe"
 version = "0.3.2"
@@ -599,6 +932,13 @@ dependencies = [
  "defmt",
 ]
 
+[[package]]
+name = "pid"
+version = "0.1.0"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.14"
@@ -611,6 +951,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "portable-atomic"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
+
 [[package]]
 name = "proc-macro-error"
 version = "1.0.4"
@@ -659,6 +1005,21 @@ version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
 
+[[package]]
+name = "rppal"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae44db779bd0898047804d22b662a9dc533b142c077b3f7e36003f658835c5b9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
 [[package]]
 name = "rustc_version"
 version = "0.2.3"
@@ -695,6 +1056,15 @@ version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
 
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
 [[package]]
 name = "stable_deref_trait"
 version = "1.2.0"
@@ -735,6 +1105,8 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 name = "support"
 version = "0.1.0"
 dependencies = [
+ "bootloader-params",
+ "cortex-m",
  "defmt",
  "embassy-executor",
  "embassy-stm32",
@@ -784,12 +1156,81 @@ dependencies = [
  "syn 2.0.71",
 ]
 
+[[package]]
+name = "thread_local"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-error"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
+dependencies = [
+ "sharded-slab",
+ "thread_local",
+ "tracing-core",
+]
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
 
+[[package]]
+name = "unicode-width"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+
 [[package]]
 name = "vcell"
 version = "0.1.3"
@@ -816,3 +1257,76 @@ checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc"
 dependencies = [
  "vcell",
 ]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/Cargo.toml b/Cargo.toml
index c36c716e725e40e3e967bda91a123889c35741cc..7e4e9ef51b22073fd2ad09977cd22a8f312b2bb8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,17 @@
 [workspace]
-members = ["controller", "i2c2-target", "support"]
-exclude = ["build-support", "pid"]
+members = ["bootloader", "bootloader-params", "build-support", "controller", "firmware-updater", "i2c2-target", "pid", "support"]
 resolver = "2"
 
 [profile.release]
 codegen-units = 1
 debug = true
 lto = true
+
+[profile.production]
+inherits = "release"
+debug = false
+strip = "symbols"
+
+[profile.release.package.bootloader]
+opt-level = "z"
+strip = "symbols"
diff --git a/INSTALL.md b/INSTALL.md
index c1a589a1a5dab4ce7a6c3571276d4197b0e7d464..bc8f2196ca556fa7cad4b851337195ceeaa91c1e 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -1,5 +1,14 @@
 # Installing the motor controller
 
+The Télécom Paris Gitlab CI will contain an artifact with the latest
+version of those software:
+
+- controller.py: Python interface to the controller
+- controller: the controller firmware
+- firmware-updater: updater for the controller firmware
+- bootloader: the I²C bootloader
+- bootloader.py: limited Python interface to the I²C bootloader
+
 ## STM32
 
 You must install the appropriate Rust target and tools:
@@ -9,20 +18,64 @@ $ rustup target install thumbv7m-none-eabi
 $ cargo install probe-rs-tools
 ```
 
-Then flash the executable using
+### Flashing the bootloader
+
+You must flash the I²C bootloader using a JTAG probe:
+
+```shell
+$ cargo run --profile production -p bootloader
+```
+
+The bootloader is in charge of launching the existing controller, or
+allowing the flashing of a new one using the I²C bus without using a
+JTAG probe.
+
+### Flashing the controller
+
+You canflash the controller directly using a JTAG probe:
 
 ```shell
-$ cargo run --release -p dc-motor-driver-hat
+$ cargo run --profile production -p controller
 ```
 
 ## Raspberry Pi
 
-On the Raspberry Pi, ensure that `boot/config.txt` contains the following line which defines a software I²C bus:
+### System configuration
+
+On the Raspberry Pi, ensure that `boot/config.txt` contains the
+following line which defines a software I²C bus:
 
 ```txt
 dtoverlay=i2c-gpio,i2c_gpio_sda=2,i2c_gpio_scl=3,bus=8
 ```
 
-Ensure that the line `dtparam=i2c_arm=on` is not present: that would activate the hardware I²C bus, which cannot be used due to a hardware bug in the BCM2712 SOC with regard to clock stretching.
+Ensure that the line `dtparam=i2c_arm=on` is not present: that would
+activate the hardware I²C bus, which cannot be used due to a hardware
+bug in the BCM2712 SOC with regard to clock stretching.
 
 The Python module is located in `controller/python/controller.py`.
+
+### Firmware updater
+
+The `firmware-updater` program can be either be built on the Raspberry Pi:
+
+```shell
+$ cargo build --profile production -p firmware-updater
+```
+
+or can be cross-compiled from another machine, provided you have
+already installed `aarch-unknown-linux-musl-gcc` which is needed for
+linking:
+
+```shell
+$ cargo build --profile production -p firmware-updater --target aarch-unknown-linux-musl
+```
+
+### Updating the firmware
+
+You can update the firmware on the microcontroller by running the
+following command on the Raspberry Pi:
+
+```shell
+$ firmware-updater flash /path/to/controller
+```
diff --git a/README.org b/README.org
index 23dda6f6d6b93d455d3f45e9f4928cd56f961179..694e6cff0d4fe561f477563c14e3f1f5ea3fa130 100644
--- a/README.org
+++ b/README.org
@@ -54,7 +54,14 @@ The led pattern is in Morse code.
 | LP   | Low power (Vdd is below 2.9V)                        | .-.. .--. |
 | WW   | Last reset was due to the window watchdog trigerring | .-- .--   |
 
-* Command set
+* Motor controller command set
+
+I²C address: 0x57
+
+I²C uses clock stretching, so care must be taken when using a
+Raspberry Pi whose SOC is bogus. Using 400kHz instead of the default
+100kHz seems to mask the problem if not outputting too much debug
+information. Using a software I²C solves the issue.
 
 Multibyte values are exchanged in little endian format.
 
@@ -129,6 +136,10 @@ Multibyte values are exchanged in little endian format.
 
 - Reset the device. Used mainly for testing.
 
+** [IMPLEMENTED] 0xE1 Reset to bootloader (W)
+
+- Reset the device in bootloader mode. Used when reprogramming.
+
 ** [IMPLEMENTED] 0xF0-0xF7 Unique device ID (R)
 
 - Unique device ID as found in addresses 0x1FFFF7E8-0x1FFFF7EF
@@ -170,3 +181,102 @@ the feature executes an alternate firmware.
 - 0x01: failure
 - 0x02: command executed too soon
 - 0x03: features locked because of previous failure
+
+
+* Bootloader command set
+
+If a firmware is present, the bootloader will start it unless the
+firmware has explicitly chosen to reboot in bootloader mode.
+
+I²C address: 0x58
+
+The note about clock stretching also applies for the bootloader.
+
+** [IMPLEMENTED] 0x08 Firmware version (R)
+
+- Return three bytes containing the major, minor and patch version numbers
+  derived from =Cargo.toml=.
+
+** [IMPLEMENTED] 0x0F Who am I? (R)
+
+- The I²C address
+
+** [IMPLEMENTED] 0x10 Status (R)
+
+Returns two bytes:
+
+- One set of flags ORed together:
+    - 0x01: system is in programming mode
+    - 0x02: at least one error was detected since the last time the
+      system has been put in programming mode
+- A XORed version of all data received so far since the programming
+  address was last set, either by entering programming mode, or by
+  using the SET PROGRAMMING ADDRESS command.
+
+** [IMPLEMENTED] 0x20 Change programming mode (W)
+
+- 0: leave programming mode without marking the application as
+  succesfully written
+- 1: leave programming mode and mark the application as succesfully
+  written
+- [0x17, 0x27, 0x65, 0x40]: enter programming mode
+
+** [IMPLEMENTED] 0x24 Erase pages (W) [PROGRAMMING MODE]
+
+- First byte: index of the first 1kB page in the application area
+- Second byte: number of pages to erase (up to the end of the application area)
+- Third byte: XOR of the first two bytes, as a safety
+
+The memory will be erased. The active programming address is then set to
+the beginning of the erased area.
+
+Even if the last page of the application does not belong to the erased
+page set, it will be erased with the first erase request of the
+current session (or after having being set again) to ensure that the
+application is no longer considered valid.
+
+Designating address outside the application area, or having a wrong checksum,
+will be an error.
+
+** [IMPLEMENTED] 0x28 Set programming address (W) [PROGRAMMING MODE]
+
+- 4 bytes containing the next address to program, relative to the
+  application start
+- One byte XORing the previous ones
+
+** [IMPLEMENTED] 0x2c Program data (W) [PROGRAMMING MODE]
+
+- 2, 4, 6, or 8 data bytes
+- One byte XORing the previous ones
+
+In case of error, the bootloader leaves programming mode immediately.
+
+The programming address will advance with the right number of bytes.
+
+** [IMPLEMENTED] 0x30 Read memory (R) [PROGRAMMING MODE]
+
+- Read 8 consecutive bytes in memory from the programming address
+  (relative to the applicatin start), and advance it by 8.
+
+0 will be returned outside the application memory area.
+
+** [IMPLEMENTED] 0x34 Set checksum (W) [PROGRAMMING_MODE]
+
+- Set the checksum value to the given byte.
+
+** [IMPLEMENTED] 0xE0 Reset (W)
+
+- Reset the bootloader in automatic mode
+
+** [IMPLEMENTED] 0xE1 Reset to the bootloader (W)
+
+- Reset the bootloader in bootloader mode
+
+** [IMPLEMENTED] 0xE2 Reset to the application (W)
+
+- Reset the bootloader in application mode
+
+** [IMPLEMENTED] 0xF0-0xF7 Unique device ID (R)
+
+- Unique device ID as found in addresses 0x1FFFF7E8-0x1FFFF7EF
+
diff --git a/bootloader-params/Cargo.toml b/bootloader-params/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..43462db142195edc49cc182a933d6b9d27f13c16
--- /dev/null
+++ b/bootloader-params/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "bootloader-params"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
diff --git a/bootloader-params/src/lib.rs b/bootloader-params/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..19338752161216513b7dcc5c81c9e339e9a623b6
--- /dev/null
+++ b/bootloader-params/src/lib.rs
@@ -0,0 +1,28 @@
+//! This crate will be used both by the embedded system and by the
+//! build system.
+
+#![no_std]
+
+pub const FLASH_START: u32 = 0x0800_0000;
+pub const FLASH_SIZE: u32 = 0x10000;
+pub const FLASH_PAGE_SIZE: u32 = 1024;
+pub const FLASH_PAGES: u32 = FLASH_SIZE / FLASH_PAGE_SIZE;
+pub const BOOTLOADER_PAGES: u32 = 24;
+pub const BOOTLOADER_SIZE: u32 = BOOTLOADER_PAGES * FLASH_PAGE_SIZE;
+pub const APPLICATION_SIZE: u32 = FLASH_SIZE - BOOTLOADER_SIZE - 4;
+
+pub mod commands {
+    pub const CMD_FIRMWARE_VERSION: u8 = 0x08;
+    pub const CMD_WHO_AM_I: u8 = 0x0f;
+    pub const CMD_REBOOT_AUTO: u8 = 0xe0;
+    pub const CMD_REBOOT_BOOTLOADER: u8 = 0xe1;
+    pub const CMD_REBOOT_APPLICATION: u8 = 0xe2;
+    pub const CMD_DEVICE_ID: u8 = 0xf0;
+    pub const CMD_STATUS: u8 = 0x10;
+    pub const CMD_CHANGE_PROGRAMMING_MODE: u8 = 0x20;
+    pub const CMD_ERASE_PAGES: u8 = 0x24;
+    pub const CMD_SET_PROGRAMMING_ADDRESS: u8 = 0x28;
+    pub const CMD_PROGRAM_DATA: u8 = 0x2c;
+    pub const CMD_READ_MEMORY: u8 = 0x30;
+    pub const CMD_SET_CHECKSUM: u8 = 0x34;
+}
diff --git a/bootloader/.cargo/config.toml b/bootloader/.cargo/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..b800c6d6c846a76d748bc2d605317e4725641c44
--- /dev/null
+++ b/bootloader/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "thumbv7m-none-eabi"
diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..10e700810234434dbf4b62fd842a8f8d9d49b0fa
--- /dev/null
+++ b/bootloader/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "bootloader"
+description = "I²C bootloader for DC Motor Driver Hat DFR0592"
+authors = ["Samuel Tardieu <sam@rfc1149.net>"]
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+bootloader-params = { path = "../bootloader-params" }
+cortex-m = { version = "0.7.7", features = ["critical-section-single-core", "inline-asm"] }
+cortex-m-rt = "0.7.3"
+critical-section = "1.1.2"
+defmt = "0.3.8"
+defmt-rtt = "0.4.1"
+embassy-executor = { git = "https://github.com/embassy-rs/embassy", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
+embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "stm32f103c8", "unstable-pac", "time-driver-tim4"] }
+embassy-sync = { git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
+embassy-time = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
+futures = { version = "0.3.30", default-features = false }
+heapless = "0.8.0"
+i2c2-target = { path = "../i2c2-target" }
+panic-probe = { version = "0.3.2", features = ["print-defmt"] }
+support = { version = "0.1.0", path = "../support" }
+
+[lints.clippy]
+pedantic = "deny"
+
+[build-dependencies]
+bootloader-params = { path = "../bootloader-params" }
+build-support = { path = "../build-support" }
diff --git a/bootloader/build.rs b/bootloader/build.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c4e01d8ad3d8804c41e2c57bf10aad31169d749b
--- /dev/null
+++ b/bootloader/build.rs
@@ -0,0 +1,12 @@
+use bootloader_params::{BOOTLOADER_SIZE, FLASH_START};
+use build_support::MemoryConfig;
+
+fn main() -> Result<(), build_support::Error> {
+    build_support::check_python_library_consistency("python/bootloader.py")?;
+    build_support::make_version()?;
+    build_support::make_memory_x(MemoryConfig {
+        flash_start: FLASH_START,
+        flash_size: BOOTLOADER_SIZE,
+    })?;
+    Ok(())
+}
diff --git a/bootloader/python/bootloader.py b/bootloader/python/bootloader.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbb75448f9ef198602ecd446aaacca7dd347df95
--- /dev/null
+++ b/bootloader/python/bootloader.py
@@ -0,0 +1,90 @@
+import struct
+
+# Major and minor version of required firmware
+_REQUIRED_FIRMWARE_VERSION = (0, 1)
+
+
+class FirmwareVersionMismatch(Exception):
+    pass
+
+
+class WhoAmIMismatch(Exception):
+    pass
+
+
+class Bootloader:
+
+    I2C_ADDR = 0x58
+
+    FIRMWARE_VERSION = 0x08
+    WHO_AM_I = 0x0F
+    RESET = 0xE0
+    DEVICE_ID = 0xF0
+
+    def __init__(self, i2c_bus=8):
+        import smbus
+
+        self.i2c_bus = i2c_bus
+        self.i2c = smbus.SMBus(self.i2c_bus)
+
+        self.check_who_am_i()
+        self.check_firmware_version()
+
+    def _read(self, command, n, unpack_spec) -> tuple:
+        return struct.unpack(
+            unpack_spec,
+            bytes(self.i2c.read_i2c_block_data(self.I2C_ADDR, command, n)),
+        )
+
+    def _write(self, command: int, data: list[int]):
+        self.i2c.write_i2c_block_data(self.I2C_ADDR, command, data)
+
+    def who_am_i(self) -> int:
+        """Check that the motors controller board is present. This
+        should return the same value as Bootloader.I2C_ADDR."""
+        return self._read(self.WHO_AM_I, 1, "B")[0]
+
+    def check_who_am_i(self):
+        """Check that the device answers to WHO_AM_I is correct."""
+        w = self.who_am_i()
+        if w != self.I2C_ADDR:
+            error = (
+                f"WHO_AM_I returns {w:#04x} "
+                f"instead of the expected {self.I2C_ADDR:#04x}"
+            )
+            raise WhoAmIMismatch(error)
+
+    def get_firmware_version(self) -> tuple[int, int, int]:
+        """Get the firmware version (major, minor, patch)."""
+        return self._read(self.FIRMWARE_VERSION, 3, "BBB")
+
+    def check_firmware_version(self):
+        """Check that the firmware uses a version compatible with this
+        library."""
+        version = self.get_firmware_version()
+        Bootloader._check_firmware_version_consistency(
+            _REQUIRED_FIRMWARE_VERSION, version
+        )
+
+    def _check_firmware_version_consistency(
+        required: tuple[int, int], version: tuple[int, int, int]
+    ):
+        (MAJOR, MINOR) = required
+        (major, minor, patch) = version
+        error = None
+        if major != MAJOR or minor < MINOR:
+            version = f"{major}.{minor}.{patch}"
+            VERSION = f"{MAJOR}.{MINOR}.*"
+            error = (
+                f"Hardware runs firmware version {version} which "
+                f"is not compatible with this library version ({VERSION})"
+            )
+            raise FirmwareVersionMismatch(error)
+
+    def reset(self):
+        """Reset the device. Used mainly for testing."""
+        self._write(self.RESET, [])
+
+    def get_device_id(self):
+        """Return the 8 bytes composing the device id."""
+        return list(self._read(self.DEVICE_ID, 8, "BBBBBBBB"))
diff --git a/bootloader/src/bootloader.rs b/bootloader/src/bootloader.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9148aac60b8dcf5799c3b1c4377abf0cdeeb5bf7
--- /dev/null
+++ b/bootloader/src/bootloader.rs
@@ -0,0 +1,77 @@
+use crate::flash::ProgrammingState;
+use bootloader_params::commands::{
+    CMD_DEVICE_ID, CMD_FIRMWARE_VERSION, CMD_REBOOT_APPLICATION, CMD_REBOOT_AUTO,
+    CMD_REBOOT_BOOTLOADER, CMD_WHO_AM_I,
+};
+use embassy_executor::Spawner;
+use embassy_stm32::peripherals::FLASH;
+use heapless::Vec;
+use i2c2_target::{I2C2, MESSAGE_SIZE};
+use support::boot_control;
+
+pub const I2C_ADDRESS: u8 = 0x58;
+
+struct State {
+    _i2c: I2C2,
+    programming_state: ProgrammingState,
+}
+
+impl State {
+    fn new(i2c2: I2C2, flash: FLASH) -> Self {
+        Self {
+            _i2c: i2c2,
+            programming_state: ProgrammingState::new(flash),
+        }
+    }
+}
+
+#[allow(clippy::needless_pass_by_value, clippy::used_underscore_binding)] // Clippy bug
+pub fn start_i2c_target(spawner: Spawner, i2c2: I2C2, flash: FLASH) {
+    let state = State::new(i2c2, flash);
+    spawner.spawn(i2c_engine(state)).unwrap();
+}
+
+#[embassy_executor::task]
+async fn i2c_engine(state: State) {
+    I2C2::start(&i2c_callback, state).await;
+}
+
+include!(concat!(env!("OUT_DIR"), "/version.rs"));
+
+fn i2c_callback(command: &[u8], response: &mut Vec<u8, MESSAGE_SIZE>, state: &mut State) {
+    defmt::trace!("Processing command {:?}", command);
+    match command {
+        _ if state
+            .programming_state
+            .programming_callback(command, response) => {}
+        [CMD_FIRMWARE_VERSION] => {
+            for b in PKG_VERSION {
+                response.push(b).unwrap();
+            }
+        }
+        [CMD_WHO_AM_I] => {
+            response.push(I2C_ADDRESS).unwrap();
+        }
+        [CMD_REBOOT_AUTO] => {
+            defmt::info!("restarting in auto mode");
+            boot_control::next_boot_is_auto();
+            boot_control::reboot();
+        }
+        [CMD_REBOOT_BOOTLOADER] => {
+            defmt::info!("restarting in bootloader mode");
+            boot_control::reboot_to_bootloader();
+        }
+        [CMD_REBOOT_APPLICATION] => {
+            defmt::info!("restarting in application mode");
+            boot_control::reboot_to_application();
+        }
+        [CMD_DEVICE_ID] => {
+            for &b in support::device_id() {
+                response.push(b).unwrap();
+            }
+        }
+        _ => {
+            defmt::warn!("unknown command or args {:#04x}", command);
+        }
+    }
+}
diff --git a/bootloader/src/flash.rs b/bootloader/src/flash.rs
new file mode 100644
index 0000000000000000000000000000000000000000..41d3f3840891a11d27654ea0f775268bc8ba868e
--- /dev/null
+++ b/bootloader/src/flash.rs
@@ -0,0 +1,265 @@
+use bootloader_params::{
+    commands::{
+        CMD_CHANGE_PROGRAMMING_MODE, CMD_ERASE_PAGES, CMD_PROGRAM_DATA, CMD_READ_MEMORY,
+        CMD_SET_CHECKSUM, CMD_SET_PROGRAMMING_ADDRESS, CMD_STATUS,
+    },
+    APPLICATION_SIZE, BOOTLOADER_PAGES, BOOTLOADER_SIZE, FLASH_PAGES, FLASH_PAGE_SIZE, FLASH_START,
+};
+use embassy_stm32::{
+    flash::{self, Blocking, Flash},
+    pac,
+    peripherals::FLASH,
+};
+use heapless::Vec;
+use i2c2_target::MESSAGE_SIZE;
+use support::boot_control::APPLICATION_MAGIC;
+
+pub struct ProgrammingState {
+    flash: Flash<'static, Blocking>,
+    programming_mode: bool,
+    programming_error: bool,
+    application_marker_erased: bool,
+    programming_address: u32,
+    checksum: u8,
+}
+
+impl ProgrammingState {
+    #[must_use]
+    pub fn new(flash: FLASH) -> Self {
+        Self {
+            flash: Flash::new_blocking(flash),
+            programming_mode: false,
+            programming_error: false,
+            application_marker_erased: false,
+            programming_address: 0,
+            checksum: 0,
+        }
+    }
+
+    fn programming_error(&mut self) {
+        self.programming_mode = false;
+        self.programming_error = true;
+        self.checksum = 0;
+    }
+
+    fn enter_programming_mode(&mut self) {
+        self.programming_mode = true;
+        self.programming_error = false;
+        self.programming_address = 0;
+        self.checksum = 0;
+    }
+
+    fn leave_programming_mode(&mut self, register_app: bool) {
+        if register_app && !self.programming_error && self.application_marker_erased {
+            if apm32_blocking_write(BOOTLOADER_SIZE + APPLICATION_SIZE, &APPLICATION_MAGIC).is_err()
+            {
+                defmt::error!("error when programming the application marker");
+                self.programming_error();
+                return;
+            }
+            self.application_marker_erased = false;
+        }
+        self.programming_mode = false;
+        self.checksum = 0;
+    }
+
+    /// Execute a command and enter programming mode error if it
+    /// returns `None`.
+    fn check_command(&mut self, f: impl FnOnce(&mut Self) -> Option<()>) {
+        if f(self).is_none() {
+            self.programming_error();
+        }
+    }
+
+    /// Handle a programming request. Return `true` if the command has
+    /// been handled, `false` otherwise.
+    ///
+    /// # Panics
+    /// This function may panic if the buffer size is exceeded. There
+    /// is nothing we can do here.
+    #[allow(clippy::too_many_lines)]
+    pub fn programming_callback(
+        &mut self,
+        command: &[u8],
+        response: &mut Vec<u8, MESSAGE_SIZE>,
+    ) -> bool {
+        match command {
+            [CMD_STATUS] => {
+                let flags =
+                    u8::from(self.programming_mode) | (u8::from(self.programming_error) << 1);
+                response.push(flags).unwrap();
+                response.push(self.checksum).unwrap();
+            }
+            [CMD_CHANGE_PROGRAMMING_MODE, 0x17, 0x27, 0x65, 0x40] => {
+                if self.programming_mode {
+                    self.programming_error();
+                } else {
+                    self.enter_programming_mode();
+                }
+            }
+            _ if !self.programming_mode => {
+                // We do not recognize other commands outside programming mode
+                return false;
+            }
+            [CMD_CHANGE_PROGRAMMING_MODE, register_app @ (0x00 | 0x01)] => {
+                self.leave_programming_mode(*register_app == 0x01);
+            }
+            [CMD_ERASE_PAGES, data @ ..] if data.len() == 3 => {
+                self.check_command(|this| {
+                    let data = unxored(data)?;
+                    let (index, num) = (u32::from(data[0]), u32::from(data[1]));
+                    if index + num > FLASH_PAGES - BOOTLOADER_PAGES {
+                        return None;
+                    }
+                    if this
+                        .flash
+                        .blocking_erase(
+                            (BOOTLOADER_PAGES + index) * FLASH_PAGE_SIZE,
+                            (BOOTLOADER_PAGES + index + num) * FLASH_PAGE_SIZE,
+                        )
+                        .is_err()
+                    {
+                        defmt::error!(
+                            "cannot erase {} pages starting from application page {}",
+                            num,
+                            index
+                        );
+                        return None;
+                    }
+                    if index + num < FLASH_PAGES - BOOTLOADER_PAGES
+                        && !this.application_marker_erased
+                        && this
+                            .flash
+                            .blocking_erase(
+                                (FLASH_PAGES - 1) * FLASH_PAGE_SIZE,
+                                FLASH_PAGES * FLASH_PAGE_SIZE,
+                            )
+                            .is_err()
+                    {
+                        defmt::error!("cannot erase application marker page");
+                        return None;
+                    }
+                    this.application_marker_erased = true;
+                    Some(())
+                });
+            }
+            [CMD_SET_PROGRAMMING_ADDRESS, data @ ..] => {
+                self.check_command(|this| {
+                    let address = u32::from_le_bytes(unxored(data)?.try_into().ok()?);
+                    if address < APPLICATION_SIZE {
+                        this.programming_address = address;
+                        Some(())
+                    } else {
+                        None
+                    }
+                });
+            }
+            [CMD_PROGRAM_DATA, data @ ..] => {
+                self.check_command(|this| {
+                    let data = unxored(data)?;
+                    if data.len() > 8 || data.len() % 2 != 0 {
+                        return None;
+                    }
+                    let data_len = u32::try_from(data.len()).unwrap(); // This cannot fail
+                    if this.programming_address + data_len > APPLICATION_SIZE {
+                        return None;
+                    }
+                    if let Err(e) =
+                        apm32_blocking_write(BOOTLOADER_SIZE + this.programming_address, data)
+                    {
+                        defmt::error!(
+                            "cannot program data at {:#x} ({:#x}): {}",
+                            this.programming_address,
+                            data,
+                            e
+                        );
+                        return None;
+                    }
+                    this.programming_address += data_len as u32;
+                    for b in data {
+                        this.checksum ^= *b;
+                    }
+                    Some(())
+                });
+            }
+            [CMD_READ_MEMORY] => {
+                for _ in 0..8 {
+                    let b = match self.programming_address {
+                        addr if addr < APPLICATION_SIZE => {
+                            self.programming_address += 1;
+                            unsafe { *((FLASH_START + BOOTLOADER_SIZE + addr) as *const u8) }
+                        }
+                        _ => 0,
+                    };
+                    response.push(b).unwrap();
+                }
+            }
+            [CMD_SET_CHECKSUM, checksum] => {
+                self.checksum = *checksum;
+            }
+            _ => {
+                // This is not a valid programming mode command
+                return false;
+            }
+        }
+        true
+    }
+}
+
+/// Check XOR-ed data. data must not contain zero or one byte,
+/// and the last byte must be a xor of previous bytes. All bytes
+/// except the checksum are returned, or `None` if there is a
+/// mismatch.
+fn unxored(data: &[u8]) -> Option<&[u8]> {
+    match data {
+        [data @ .., checksum] if !data.is_empty() => {
+            (data.iter().fold(0, |a, b| a ^ *b) == *checksum).then_some(data)
+        }
+        _ => None,
+    }
+}
+
+/// The APM32 requires that the BSY flag is checked before performing a new write,
+/// while on STM32 two writes can be chained.
+fn apm32_blocking_write(address: u32, data: &[u8]) -> Result<(), flash::Error> {
+    let words = data
+        .chunks_exact(2)
+        .map(|c| u16::from_le_bytes(c.try_into().unwrap()));
+    let addresses = (FLASH_START + address..).step_by(2).map(|a| a as *mut u16);
+    // Clear errors
+    pac::FLASH.sr().modify(|_| {});
+    // Unlock
+    if pac::FLASH.cr().read().lock() {
+        pac::FLASH.keyr().write_value(0x4567_0123);
+        pac::FLASH.keyr().write_value(0xCDEF_89AB);
+    }
+    // Enable write
+    pac::FLASH.cr().modify(|w| w.set_pg(true));
+    // Write every word and wait for BSY to clear
+    for (address, word) in addresses.zip(words) {
+        unsafe {
+            core::ptr::write_volatile(address, word);
+        }
+        loop {
+            let sr = pac::FLASH.sr().read();
+            if sr.pgerr() {
+                apm32_unlock();
+                return Err(flash::Error::Prog);
+            }
+            if sr.wrprterr() {
+                apm32_unlock();
+                return Err(flash::Error::Protected);
+            }
+            if !sr.bsy() {
+                break;
+            }
+        }
+    }
+    // Disable write and lock
+    apm32_unlock();
+    Ok(())
+}
+
+fn apm32_unlock() {
+    pac::FLASH.cr().write(|w| w.set_pg(false));
+}
diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c4d2cc96a9d9624669472a5915f486de90966097
--- /dev/null
+++ b/bootloader/src/main.rs
@@ -0,0 +1,118 @@
+#![no_std]
+#![no_main]
+
+use defmt_rtt as _;
+use embassy_executor::Spawner;
+use embassy_stm32::{
+    interrupt::Priority,
+    pac,
+    peripherals::RCC,
+    rcc::{APBPrescaler, Hse, HseMode, Pll, PllMul, PllPreDiv, PllSource, Sysclk},
+    time::mhz,
+    Config,
+};
+use panic_probe as _;
+use support::{blinker, boot_control, power};
+
+mod bootloader;
+pub mod flash;
+
+#[derive(Clone, Copy, Debug)]
+enum ResetCause {
+    ExternalReset,
+    WindowWatchdogReset,
+    IndependentWatchdogReset,
+    SoftwareReset,
+    LowPowerManagementReset,
+    PowerReset,
+    Unknown,
+}
+
+fn reset_cause(_rcc: &mut RCC) -> ResetCause {
+    // Read reset cause, and reset it
+    let csr = pac::RCC.csr().read();
+    pac::RCC.csr().modify(|w| w.set_rmvf(true));
+    if csr.lpwrrstf() {
+        ResetCause::LowPowerManagementReset
+    } else if csr.wwdgrstf() {
+        ResetCause::WindowWatchdogReset
+    } else if csr.iwdgrstf() {
+        ResetCause::IndependentWatchdogReset
+    } else if csr.sftrstf() {
+        ResetCause::SoftwareReset
+    } else if csr.porrstf() {
+        ResetCause::PowerReset
+    } else if csr.pinrstf() {
+        ResetCause::ExternalReset
+    } else {
+        ResetCause::Unknown
+    }
+}
+
+#[embassy_executor::main]
+async fn main(spawner: Spawner) {
+    if boot_control::is_application_mode() {
+        boot_control::next_boot_is_auto();
+        unsafe {
+            boot_control::jump_to_application();
+        }
+    }
+
+    let mut config = Config::default();
+    // Use 8MHz external oscillator
+    config.rcc.hse = Some(Hse {
+        freq: mhz(8),
+        mode: HseMode::Oscillator,
+    });
+    // Use 72MHz (HSE×9) as system clock
+    config.rcc.sys = Sysclk::PLL1_P;
+    config.rcc.pll = Some(Pll {
+        src: PllSource::HSE,
+        prediv: PllPreDiv::DIV1,
+        mul: PllMul::MUL9,
+    });
+    // APB1 speed (PCLK1) is max 36MHz (SysClk/2), use that
+    config.rcc.apb1_pre = APBPrescaler::DIV2;
+    let mut p = embassy_stm32::init(config);
+    let reset_cause = reset_cause(&mut p.RCC);
+
+    defmt::info!(
+        "Bootloader firmware {}.{}.{} starting – reset cause: {}",
+        bootloader::PKG_VERSION[0],
+        bootloader::PKG_VERSION[1],
+        bootloader::PKG_VERSION[2],
+        defmt::Debug2Format(&reset_cause),
+    );
+
+    support::identify();
+
+    if boot_control::is_application_present() {
+        defmt::info!("An application has been detected");
+    } else {
+        defmt::info!("No application has been detected");
+    }
+
+    let pattern = if boot_control::is_bootloader_mode() {
+        defmt::info!("Detected forced bootloader mode");
+        boot_control::next_boot_is_auto();
+        "-..." // B for bootloader
+    } else if boot_control::is_application_present() {
+        defmt::info!("Application present, rebooting in application mode");
+        boot_control::reboot_to_application();
+    } else {
+        defmt::info!("No application present, staying in bootloader mode");
+        "-. .-" // NA for no application
+    };
+    spawner.spawn(blinker::blink(p.PB15, pattern)).unwrap();
+
+    spawner.spawn(power::watch_power_level()).unwrap();
+
+    let i2c2 = i2c2_target::I2C2::new(
+        p.I2C2,
+        p.PB10,
+        p.PB11,
+        bootloader::I2C_ADDRESS.into(),
+        Priority::P3,
+    );
+    bootloader::start_i2c_target(spawner, i2c2, p.FLASH);
+}
diff --git a/build-support/src/lib.rs b/build-support/src/lib.rs
index dab75ba9dcc5ee6ce7b168582fa3b989676e23cf..5741830f91291a77d44f44f7e4259529819cd9dd 100644
--- a/build-support/src/lib.rs
+++ b/build-support/src/lib.rs
@@ -15,6 +15,8 @@ pub enum Error {
     MissingEnv(#[from] env::VarError),
     #[error(transparent)]
     BadSemVer(#[from] semver::Error),
+    #[error("semver component does not fit in a u8")]
+    LargeSemVer(#[from] std::num::TryFromIntError),
 }
 
 fn version() -> Result<Version, Error> {
@@ -38,13 +40,33 @@ pub fn check_python_library_consistency(python_file: &str) -> Result<(), Error>
 
 pub fn make_version() -> Result<(), Error> {
     let version = version()?;
-    let mut out = File::create(Path::new(&env::var("OUT_DIR").unwrap()).join("version.rs"))?;
+    let mut out = File::create(Path::new(&env::var("OUT_DIR")?).join("version.rs"))?;
     writeln!(
         out,
         "pub const PKG_VERSION: [u8; 3] = [{}, {}, {}];",
-        u8::try_from(version.major).unwrap(),
-        u8::try_from(version.minor).unwrap(),
-        u8::try_from(version.patch).unwrap()
+        u8::try_from(version.major)?,
+        u8::try_from(version.minor)?,
+        u8::try_from(version.patch)?,
     )?;
     Ok(())
 }
+
+pub struct MemoryConfig {
+    pub flash_start: u32,
+    pub flash_size: u32,
+}
+
+pub fn make_memory_x(config: MemoryConfig) -> Result<(), Error> {
+    let out_dir = env::var("OUT_DIR")?;
+    let mut out = File::create(Path::new(&out_dir).join("memory.x"))?;
+    writeln!(out, "MEMORY {{")?;
+    writeln!(
+        out,
+        "FLASH : ORIGIN = {}, LENGTH = {}",
+        config.flash_start, config.flash_size
+    )?;
+    writeln!(out, "RAM : ORIGIN = 0x20000000, LENGTH = 20K")?;
+    writeln!(out, "}}")?;
+    println!("cargo::rustc-link-search={}", out_dir);
+    Ok(())
+}
diff --git a/controller/.cargo/config.toml b/controller/.cargo/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..b800c6d6c846a76d748bc2d605317e4725641c44
--- /dev/null
+++ b/controller/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "thumbv7m-none-eabi"
diff --git a/controller/Cargo.toml b/controller/Cargo.toml
index 08c61521f7ad4ef0a14c49238404e54a1eaf37a4..c69a0b4d5719a1aa31ff2c1e393f3a6547a226d3 100644
--- a/controller/Cargo.toml
+++ b/controller/Cargo.toml
@@ -1,7 +1,8 @@
 [package]
-name = "dc-motor-driver-hat"
-version = "0.3.0"  # Update in python/controller.py as well
+name = "controller"
+description = "Firmware for DC Motor Driver Hat DFR0592"
 authors = ["Samuel Tardieu <sam@rfc1149.net>"]
+version = "0.3.1"  # Update in python/controller.py as well
 edition = "2021"
 
 [dependencies]
@@ -11,7 +12,7 @@ critical-section = "1.1.2"
 defmt = "0.3.8"
 defmt-rtt = "0.4.1"
 embassy-executor = { git = "https://github.com/embassy-rs/embassy", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
-embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "stm32f103c8", "unstable-pac", "time-driver-tim4", "memory-x"] }
+embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "stm32f103c8", "unstable-pac", "time-driver-tim4"] }
 embassy-sync = { git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
 embassy-time = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
 futures = { version = "0.3.30", default-features = false }
@@ -24,4 +25,5 @@ support = { version = "0.1.0", path = "../support" }
 pedantic = "deny"
 
 [build-dependencies]
+bootloader-params = { path = "../bootloader-params" }
 build-support = { path = "../build-support" }
diff --git a/controller/build.rs b/controller/build.rs
index e671c0eb135d410db17bf49745aaf88847c6174a..2c4bac303b584503d4dfe31b5d2289f72d3107cf 100644
--- a/controller/build.rs
+++ b/controller/build.rs
@@ -1,5 +1,12 @@
+use bootloader_params::{BOOTLOADER_SIZE, FLASH_SIZE, FLASH_START};
+use build_support::MemoryConfig;
+
 fn main() -> Result<(), build_support::Error> {
     build_support::check_python_library_consistency("python/controller.py")?;
     build_support::make_version()?;
+    build_support::make_memory_x(MemoryConfig {
+        flash_start: FLASH_START + BOOTLOADER_SIZE,
+        flash_size: FLASH_SIZE - BOOTLOADER_SIZE - 4,
+    })?;
     Ok(())
 }
diff --git a/controller/python/controller.py b/controller/python/controller.py
index 9c5e9fb7086953aec544c08525959fd8da43dd64..94f3785f99cdaf89f7a0fc8479f55ab09ad42824 100644
--- a/controller/python/controller.py
+++ b/controller/python/controller.py
@@ -27,6 +27,7 @@ class Controller:
     ENCODER_TICKS = 0x32
     STATUS = 0x36
     RESET = 0xE0
+    RESET_TO_BOOTLOADER = 0xE1
     DEVICE_ID = 0xF0
 
     def __init__(self, i2c_bus=8):
@@ -183,6 +184,10 @@ class Controller:
         """Reset the device. Used mainly for testing."""
         self._write(self.RESET, [])
 
+    def reset_to_bootloader(self):
+        """Reset the device to bootloader mode. Used for reprogramming."""
+        self._write(self.RESET_TO_BOOTLOADER, [])
+
     def get_device_id(self):
         """Return the 8 bytes composing the device id."""
         return list(self._read(self.DEVICE_ID, 8, "BBBBBBBB"))
diff --git a/controller/src/logic/mod.rs b/controller/src/logic/mod.rs
index a54caf0ad18881189183e53e6756f27e594efc48..5c6d2fb93ac3812cc6aa8c5d30ae7bcd21f0b2fc 100644
--- a/controller/src/logic/mod.rs
+++ b/controller/src/logic/mod.rs
@@ -4,10 +4,13 @@ use embassy_stm32::{peripherals::WWDG, time::hz};
 use futures::FutureExt;
 use heapless::Vec;
 use i2c2_target::{I2C2, MESSAGE_SIZE};
+use support::boot_control;
 
 mod motor_control;
 mod watchdog;
 
+pub const I2C_ADDRESS: u8 = 0x57;
+
 struct State {
     _i2c: I2C2,
     encoders: Encoders<'static>,
@@ -60,6 +63,7 @@ const CMD_MOTOR_SPEED: u8 = 0x30;
 const CMD_ENCODER_TICKS: u8 = 0x32;
 const CMD_STATUS: u8 = 0x36;
 const CMD_RESET: u8 = 0xe0;
+const CMD_RESET_BOOTLOADER: u8 = 0xe1;
 const CMD_DEVICE_ID: u8 = 0xf0;
 
 include!(concat!(env!("OUT_DIR"), "/version.rs"));
@@ -78,7 +82,7 @@ fn process_command(
             }
         }
         [CMD_WHO_AM_I] => {
-            response.push(0x57).unwrap();
+            response.push(I2C_ADDRESS).unwrap();
         }
         &[CMD_PWM_FREQUENCY, a, b, c] => {
             let f = u32::from_le_bytes([a, b, c, 0]);
@@ -152,7 +156,11 @@ fn process_command(
         }
         [CMD_RESET] => {
             defmt::info!("resetting device after receiving the RESET command");
-            cortex_m::peripheral::SCB::sys_reset();
+            boot_control::reboot();
+        }
+        [CMD_RESET_BOOTLOADER] => {
+            defmt::info!("resetting device into bootloader mode");
+            boot_control::reboot_to_bootloader();
         }
         [CMD_DEVICE_ID] => {
             for &b in support::device_id() {
diff --git a/controller/src/main.rs b/controller/src/main.rs
index 945546284420713b41d02b41b55e88a68f4c3313..40b8ecb1563eb0d95666a4c84ef267a97bc6c6dc 100644
--- a/controller/src/main.rs
+++ b/controller/src/main.rs
@@ -108,7 +108,13 @@ async fn main(spawner: Spawner) {
 
     let encoders = Encoders::new(p.PA0, p.PA1, p.PA6, p.PA7, p.TIM2, p.TIM3);
 
-    let i2c2 = i2c2_target::I2C2::new(p.I2C2, p.PB10, p.PB11, 0x57, Priority::P3);
+    let i2c2 = i2c2_target::I2C2::new(
+        p.I2C2,
+        p.PB10,
+        p.PB11,
+        logic::I2C_ADDRESS.into(),
+        Priority::P3,
+    );
 
     logic::start_i2c_target(spawner, i2c2, motors, encoders, p.WWDG);
 }
diff --git a/firmware-updater/.cargo/config.toml b/firmware-updater/.cargo/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..c9b5c99dd1014720cd3cb343aa995dfe26adad94
--- /dev/null
+++ b/firmware-updater/.cargo/config.toml
@@ -0,0 +1,2 @@
+[target.aarch64-unknown-linux-musl]
+linker = "aarch64-unknown-linux-musl-gcc"
diff --git a/firmware-updater/.envrc b/firmware-updater/.envrc
new file mode 100644
index 0000000000000000000000000000000000000000..1d953f4bd73593aba0a2af3db2d14178e2b8b9fe
--- /dev/null
+++ b/firmware-updater/.envrc
@@ -0,0 +1 @@
+use nix
diff --git a/firmware-updater/Cargo.lock b/firmware-updater/Cargo.lock
new file mode 100644
index 0000000000000000000000000000000000000000..c8a818ca667f31603c3ef16f67d14bd64aaa911f
--- /dev/null
+++ b/firmware-updater/Cargo.lock
@@ -0,0 +1,358 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "anstream"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "bootloader-params"
+version = "0.1.0"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+
+[[package]]
+name = "console"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
+dependencies = [
+ "encode_unicode",
+ "lazy_static",
+ "libc",
+ "unicode-width",
+ "windows-sys",
+]
+
+[[package]]
+name = "elf"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b"
+
+[[package]]
+name = "encode_unicode"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+
+[[package]]
+name = "firmware-updater"
+version = "0.1.0"
+dependencies = [
+ "bootloader-params",
+ "clap",
+ "elf",
+ "indicatif",
+ "rppal",
+ "thiserror",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indicatif"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3"
+dependencies = [
+ "console",
+ "instant",
+ "number_prefix",
+ "portable-atomic",
+ "unicode-width",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
+[[package]]
+name = "number_prefix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
+[[package]]
+name = "portable-atomic"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rppal"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae44db779bd0898047804d22b662a9dc533b142c077b3f7e36003f658835c5b9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/firmware-updater/Cargo.toml b/firmware-updater/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..777b55eb978cbcf1621b76981f42f85c8a6bcad8
--- /dev/null
+++ b/firmware-updater/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "firmware-updater"
+description = "Firmware updater for DC Motor Driver Hat DFR0592"
+authors = ["Samuel Tardieu <sam@rfc1149.net>"]
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+bootloader-params = { path = "../bootloader-params" }
+clap = { version = "4.5.9", features = ["cargo", "derive"] }
+color-eyre = "0.6.3"
+elf = "0.7.4"
+indicatif = "0.17.8"
+rppal = "0.18.0"
+thiserror = "1.0.63"
diff --git a/firmware-updater/default.nix b/firmware-updater/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..f1d9736d4cd426986b1486eaf062dc38f0141a11
--- /dev/null
+++ b/firmware-updater/default.nix
@@ -0,0 +1,9 @@
+with import <nixpkgs> {
+  crossSystem = {
+    #config = "aarch64-unknown-linux-gnu";
+    config = "aarch64-unknown-linux-musl";
+  };
+};
+
+mkShell {
+}
diff --git a/firmware-updater/src/bootloader.rs b/firmware-updater/src/bootloader.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f592b9e8752ce788d150ce35e26d7d3200b48b5a
--- /dev/null
+++ b/firmware-updater/src/bootloader.rs
@@ -0,0 +1,210 @@
+use bootloader_params::commands::{
+    CMD_CHANGE_PROGRAMMING_MODE, CMD_ERASE_PAGES, CMD_FIRMWARE_VERSION, CMD_PROGRAM_DATA,
+    CMD_READ_MEMORY, CMD_REBOOT_AUTO, CMD_REBOOT_BOOTLOADER, CMD_SET_CHECKSUM,
+    CMD_SET_PROGRAMMING_ADDRESS, CMD_STATUS, CMD_WHO_AM_I,
+};
+use indicatif::ProgressBar;
+use rppal::i2c::{self, I2c};
+use std::{fmt::Display, thread, time::Duration};
+
+use crate::elf_loader::Segment;
+
+pub struct Version {
+    major: u8,
+    minor: u8,
+    patch: u8,
+}
+
+impl Display for Version {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
+    }
+}
+
+impl From<&[u8; 3]> for Version {
+    fn from(value: &[u8; 3]) -> Self {
+        Self {
+            major: value[0],
+            minor: value[1],
+            patch: value[2],
+        }
+    }
+}
+
+pub enum ActiveProgram {
+    Bootloader(Version),
+    Controller(Version),
+}
+
+impl Display for ActiveProgram {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ActiveProgram::Bootloader(version) => write!(f, "bootloader {version}"),
+            ActiveProgram::Controller(version) => write!(f, "controller {version}"),
+        }
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("no active program found")]
+    NoActiveProgram,
+    #[error(transparent)]
+    I2C(#[from] i2c::Error),
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+    #[error("flash programming error")]
+    Programming,
+    #[error("checksum does not match expected content")]
+    ChecksumMismatch,
+    #[error("memory does not match expected content")]
+    MemoryContentMismatch,
+}
+
+pub type Result<T, E = Error> = std::result::Result<T, E>;
+
+#[derive(Debug)]
+pub struct Status {
+    pub programming_mode: bool,
+    pub error_detected: bool,
+    pub checksum: u8,
+}
+
+const CONTROLLER_ID: u8 = 0x57;
+const BOOTLOADER_ID: u8 = 0x58;
+
+pub fn active_program(i2c: &mut I2c) -> Result<ActiveProgram> {
+    for device in [CONTROLLER_ID, BOOTLOADER_ID] {
+        i2c.set_slave_address(u16::from(device)).unwrap();
+        let mut buffer = [0; 3];
+        if i2c.block_read(CMD_WHO_AM_I, &mut buffer[..1]).is_ok()
+            && buffer[0] == device
+            && i2c.block_read(CMD_FIRMWARE_VERSION, &mut buffer).is_ok()
+        {
+            let version = Version::from(&buffer);
+            if device == CONTROLLER_ID {
+                return Ok(ActiveProgram::Controller(version));
+            } else {
+                return Ok(ActiveProgram::Bootloader(version));
+            }
+        }
+    }
+    Err(Error::NoActiveProgram)
+}
+
+pub fn get_status(i2c: &I2c) -> Result<Status> {
+    let mut data = [0, 0];
+    i2c.block_read(CMD_STATUS, &mut data)?;
+    Ok(Status {
+        programming_mode: data[0] & 1 != 0,
+        error_detected: data[0] & 2 != 0,
+        checksum: data[1],
+    })
+}
+
+pub fn enter_programming_mode(i2c: &I2c) -> Result<()> {
+    Ok(i2c.block_write(CMD_CHANGE_PROGRAMMING_MODE, &[0x17, 0x27, 0x65, 0x40])?)
+}
+
+pub fn leave_programming_mode(i2c: &I2c, commit: bool) -> Result<()> {
+    Ok(i2c.block_write(CMD_CHANGE_PROGRAMMING_MODE, &[u8::from(commit)])?)
+}
+
+pub fn switch_to_bootloader(i2c: &I2c) -> Result<()> {
+    Ok(i2c.block_write(CMD_REBOOT_BOOTLOADER, &[])?)
+}
+
+pub fn reboot(i2c: &I2c) -> Result<()> {
+    Ok(i2c.block_write(CMD_REBOOT_AUTO, &[])?)
+}
+
+fn xor_data(data: &[u8]) -> Vec<u8> {
+    let mut v = Vec::from(data);
+    v.push(v.iter().fold(0, |a, b| a ^ *b));
+    v
+}
+
+pub fn erase_pages(i2c: &I2c, index: u8, count: u8) -> Result<()> {
+    let data = xor_data(&[index, count]);
+    i2c.block_write(CMD_ERASE_PAGES, &data)?;
+    thread::sleep(Duration::from_millis(u64::from(count + 1) * 30));
+    Ok(())
+}
+
+pub fn set_programming_address(i2c: &I2c, address: u32) -> Result<()> {
+    let data = xor_data(&address.to_le_bytes());
+    Ok(i2c.block_write(CMD_SET_PROGRAMMING_ADDRESS, &data)?)
+}
+
+fn program_data(i2c: &I2c, data: &[u8], checksum: &mut u8) -> Result<()> {
+    for b in data {
+        *checksum ^= *b;
+    }
+    let data = xor_data(data);
+    i2c.block_write(CMD_PROGRAM_DATA, &data)?;
+    Ok(())
+}
+
+fn read_memory(i2c: &I2c) -> Result<Vec<u8>> {
+    let mut res = vec![0; 8];
+    i2c.block_read(CMD_READ_MEMORY, &mut res)?;
+    Ok(res)
+}
+
+pub fn reset_checksum(i2c: &I2c) -> Result<()> {
+    Ok(i2c.block_write(CMD_SET_CHECKSUM, &[0])?)
+}
+
+pub fn check_programming_status(i2c: &I2c) -> Result<u8> {
+    let status = get_status(i2c)?;
+    if !status.programming_mode || status.error_detected {
+        return Err(Error::Programming);
+    }
+    Ok(status.checksum)
+}
+
+/// Program flash memory that has previously been erased.
+pub fn program_flash(
+    i2c: &I2c,
+    segment: &Segment,
+    checksum: &mut u8,
+    progress_bar: Option<&ProgressBar>,
+) -> Result<()> {
+    set_programming_address(i2c, segment.application_offset)?;
+    check_programming_status(i2c)?;
+    for chunk in segment.contents.chunks(8) {
+        program_data(i2c, chunk, checksum)?;
+        if let Some(bar) = progress_bar {
+            bar.inc(chunk.len() as u64);
+        }
+    }
+    let device_checksum = check_programming_status(i2c)?;
+    if *checksum != device_checksum {
+        return Err(Error::ChecksumMismatch);
+    }
+    check_programming_status(i2c)?;
+    Ok(())
+}
+
+/// Check flash memory that has previously been programmed.  The
+/// `start_address` is relative to the beginning of the application
+/// area.
+pub fn verify_flash(
+    i2c: &I2c,
+    segment: &Segment,
+    progress_bar: Option<&ProgressBar>,
+) -> Result<()> {
+    set_programming_address(i2c, segment.application_offset)?;
+    check_programming_status(i2c)?;
+    for chunk in segment.contents.chunks(8) {
+        let on_device_data = read_memory(i2c)?;
+        if chunk != &on_device_data[..chunk.len()] {
+            return Err(Error::MemoryContentMismatch);
+        }
+        if let Some(bar) = progress_bar {
+            bar.inc(chunk.len() as u64);
+        }
+    }
+    check_programming_status(i2c)?;
+    Ok(())
+}
diff --git a/firmware-updater/src/cli.rs b/firmware-updater/src/cli.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f8cd635fa65766a1a9291f0fc786afcd29c877ed
--- /dev/null
+++ b/firmware-updater/src/cli.rs
@@ -0,0 +1,56 @@
+use clap::{Args, Parser, Subcommand};
+use std::path::PathBuf;
+
+#[derive(Debug, Parser)]
+#[command(author, version, about, long_about = None)]
+pub struct Cli {
+    #[command(subcommand)]
+    pub command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum Command {
+    /// Check that firmware is well built
+    CheckFile(CheckFileArgs),
+    /// Flash firmware
+    Flash(FlashArgs),
+    /// Read the status of the program currently running
+    Status(StatusArgs),
+}
+
+#[derive(Args, Debug)]
+pub struct CheckFileArgs {
+    /// ELF file containing the firmware
+    pub firmware: PathBuf,
+}
+
+#[derive(Args, Debug)]
+pub struct CheckFlashArgs {
+    /// ELF file containing the firmware
+    pub firmware: PathBuf,
+    /// I²C bus to use
+    #[clap(short, long, default_value = "8")]
+    pub i2c_bus: u8,
+}
+
+#[derive(Args, Debug)]
+pub struct FlashArgs {
+    /// Do not verify after flashing
+    #[clap(long)]
+    pub no_verify: bool,
+    /// Do not program (verify only)
+    #[clap(short, long, conflicts_with = "no_verify")]
+    pub no_program: bool,
+    /// I²C bus to use
+    #[clap(short, long, default_value = "8")]
+    pub i2c_bus: u8,
+    /// ELF file containing the firmware
+    pub firmware: PathBuf,
+}
+
+#[derive(Args, Debug)]
+pub struct StatusArgs {
+    /// I²C bus to use
+    #[clap(short, long, default_value = "8")]
+    pub i2c_bus: u8,
+}
diff --git a/firmware-updater/src/elf_loader.rs b/firmware-updater/src/elf_loader.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ea7d56e70689b08ca2e820735ec29f91a932ef74
--- /dev/null
+++ b/firmware-updater/src/elf_loader.rs
@@ -0,0 +1,96 @@
+use std::path::Path;
+
+use bootloader_params::{APPLICATION_SIZE, BOOTLOADER_SIZE, FLASH_PAGE_SIZE, FLASH_START};
+use elf::{endian::LittleEndian, ElfBytes, ParseError};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+    #[error(transparent)]
+    Parse(#[from] ParseError),
+    #[error("no segments found in firmware file")]
+    NoSegments,
+    #[error("application from {0:#x} to {1:#x} will not fit in memory")]
+    ApplicationWontFit(u64, u64),
+    #[error("unaligned writes are not supported")]
+    UnalignedWrite,
+    #[error("compressed segments are not unsupported")]
+    CompressedSegment,
+}
+
+type Result<T, E = Error> = std::result::Result<T, E>;
+
+pub struct Firmware {
+    /// Loadable segments
+    pub segments: Vec<Segment>,
+}
+
+pub struct Segment {
+    /// Offset since the application start
+    pub application_offset: u32,
+    pub contents: Vec<u8>,
+}
+
+impl Firmware {
+    pub fn load(file: impl AsRef<Path>) -> Result<Self> {
+        let contents = std::fs::read(file)?;
+        let elf_segments = ElfBytes::<LittleEndian>::minimal_parse(&contents)?.segments();
+        let mut segments = Vec::new();
+
+        if let Some(elf_segments) = elf_segments {
+            for segment in elf_segments {
+                if segment.p_filesz > 0 {
+                    if segment.p_filesz != segment.p_memsz {
+                        return Err(Error::CompressedSegment);
+                    }
+                    if segment.p_paddr < u64::from(FLASH_START + BOOTLOADER_SIZE)
+                        || (segment.p_paddr + segment.p_memsz)
+                            > u64::from(FLASH_START + BOOTLOADER_SIZE + APPLICATION_SIZE)
+                    {
+                        return Err(Error::ApplicationWontFit(
+                            segment.p_paddr,
+                            segment.p_paddr + segment.p_memsz,
+                        ));
+                    }
+                    if segment.p_paddr % 2 != 0 || segment.p_memsz % 2 != 0 {
+                        return Err(Error::UnalignedWrite);
+                    }
+                    let segment = Segment {
+                        application_offset: segment.p_paddr as u32 - FLASH_START - BOOTLOADER_SIZE,
+                        contents: contents[segment.p_offset as usize
+                            ..(segment.p_offset + segment.p_memsz) as usize]
+                            .to_vec(),
+                    };
+                    segments.push(segment);
+                }
+            }
+        }
+        if segments.is_empty() {
+            return Err(Error::NoSegments);
+        }
+        Ok(Self { segments })
+    }
+
+    pub fn first_page(&self) -> u8 {
+        self.segments.iter().map(Segment::first_page).min().unwrap()
+    }
+
+    pub fn last_page(&self) -> u8 {
+        self.segments.iter().map(Segment::last_page).max().unwrap()
+    }
+
+    pub fn total_bytes(&self) -> usize {
+        self.segments.iter().map(|s| s.contents.len()).sum()
+    }
+}
+
+impl Segment {
+    fn first_page(&self) -> u8 {
+        (self.application_offset / FLASH_PAGE_SIZE) as u8
+    }
+
+    fn last_page(&self) -> u8 {
+        ((self.application_offset + self.contents.len() as u32 - 1) / FLASH_PAGE_SIZE) as u8
+    }
+}
diff --git a/firmware-updater/src/main.rs b/firmware-updater/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d83765042594499cb2cfdcb21bf3073d0c403444
--- /dev/null
+++ b/firmware-updater/src/main.rs
@@ -0,0 +1,247 @@
+use bootloader::ActiveProgram;
+use bootloader_params::{APPLICATION_SIZE, BOOTLOADER_PAGES, FLASH_PAGES};
+use clap::Parser;
+use cli::{CheckFileArgs, Cli, Command, FlashArgs, StatusArgs};
+use elf::ParseError;
+use elf_loader::Firmware;
+use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressFinish, ProgressStyle};
+use rppal::i2c::{self, I2c};
+use std::{borrow::Cow, fmt::Debug, sync::OnceLock, thread, time::Duration};
+
+mod bootloader;
+mod cli;
+mod elf_loader;
+
+#[derive(Debug, thiserror::Error)]
+enum Error {
+    #[error(transparent)]
+    I2C(#[from] i2c::Error),
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+    #[error(transparent)]
+    Parse(#[from] ParseError),
+    #[error("cannot run bootloader")]
+    NoBootloader,
+    #[error(transparent)]
+    Bootloader(#[from] bootloader::Error),
+    #[error(transparent)]
+    Elf(#[from] elf_loader::Error),
+}
+
+type Result<T, E = Error> = std::result::Result<T, E>;
+
+fn main() -> color_eyre::Result<()> {
+    color_eyre::install()?;
+    match Cli::parse().command {
+        Command::CheckFile(args) => cmd_check_file(args)?,
+        Command::Flash(args) => cmd_flash(args)?,
+        Command::Status(args) => cmd_status(args)?,
+    }
+    Ok(())
+}
+
+fn cmd_check_file(args: CheckFileArgs) -> Result<()> {
+    let firmware = Firmware::load(&args.firmware)?;
+    let total_bytes = firmware.total_bytes();
+    let pages = firmware.last_page() - firmware.first_page() + 1;
+    println!("Firmware looks ok:");
+    println!(
+        "  - Size in flash memory: {} ({:.1}%)",
+        HumanBytes(total_bytes as u64),
+        total_bytes as f32 * 100.0 / APPLICATION_SIZE as f32
+    );
+    println!(
+        "  - Memory pages: {} pages of 1 KiB ({:.1}%)",
+        pages,
+        pages as f32 * 100.0 / (FLASH_PAGES - BOOTLOADER_PAGES) as f32
+    );
+    Ok(())
+}
+
+fn success_msg_style() -> ProgressStyle {
+    static STYLE: OnceLock<ProgressStyle> = OnceLock::new();
+    STYLE
+        .get_or_init(|| ProgressStyle::with_template("{spinner} [{prefix:1.green}] {msg}").unwrap())
+        .clone()
+}
+
+fn failure_msg_style() -> ProgressStyle {
+    static STYLE: OnceLock<ProgressStyle> = OnceLock::new();
+    STYLE
+        .get_or_init(|| ProgressStyle::with_template("{spinner} [{prefix:1.red}] {msg}").unwrap())
+        .clone()
+}
+
+fn msg(msg: impl Into<Cow<'static, str>>) -> ProgressBar {
+    ProgressBar::new_spinner()
+        .with_message(msg)
+        .with_prefix(" ")
+        .with_style(success_msg_style())
+        .with_finish(ProgressFinish::AndLeave)
+}
+
+fn success(bar: &ProgressBar) {
+    bar.finish();
+    bar.set_style(success_msg_style());
+    bar.set_prefix("✓");
+}
+
+fn success_msg(bar: &ProgressBar, msg: impl Into<Cow<'static, str>>) {
+    bar.finish();
+    success(bar);
+    bar.set_message(msg);
+}
+
+fn failure(bar: &ProgressBar) {
+    bar.finish();
+    bar.set_style(failure_msg_style());
+    bar.set_prefix("✕");
+}
+
+fn failure_msg(bar: &ProgressBar, msg: impl Into<Cow<'static, str>>) {
+    failure(bar);
+    bar.set_message(msg);
+}
+
+fn check_success<T, E, F>(bar: &ProgressBar, f: F) -> Result<T, E>
+where
+    F: FnOnce() -> Result<T, E>,
+{
+    let result = f();
+    if result.is_ok() {
+        success(bar);
+    } else {
+        failure(bar);
+    }
+    result
+}
+
+fn cmd_flash(args: FlashArgs) -> Result<()> {
+    let bars = MultiProgress::new();
+
+    // Load firmware
+    let bar = bars.add(msg(format!("Loading input file {:?}", args.firmware)));
+    let firmware = check_success(&bar, || Firmware::load(&args.firmware))?;
+
+    // Switch to bootloader mode
+    let bar = bars.add(msg("Checking running program"));
+    let (mut i2c, program) = check_success(&bar, || {
+        let mut i2c = I2c::with_bus(args.i2c_bus)?;
+        let program = bootloader::active_program(&mut i2c)?;
+        Ok::<_, Error>((i2c, program))
+    })?;
+    bar.set_message(format!("Checking running program: {program}"));
+    if matches!(program, ActiveProgram::Controller(_)) {
+        let switch_bar = bars.add(msg("Attempting to switch to bootloader"));
+        if let Err(e) = bootloader::switch_to_bootloader(&i2c) {
+            failure(&switch_bar);
+            return Err(e.into());
+        }
+        thread::sleep(Duration::from_millis(10));
+        let bar = bars.add(msg("Checking running program"));
+        let program = check_success(&bar, || bootloader::active_program(&mut i2c))?;
+        bar.set_message(format!("Checking running program: {program}"));
+        if !matches!(program, ActiveProgram::Bootloader(_)) {
+            failure(&switch_bar);
+            return Err(Error::NoBootloader);
+        }
+        success(&switch_bar);
+    }
+
+    let bar = bars.add(msg("Entering programming mode"));
+    check_success(&bar, || {
+        let status = bootloader::get_status(&i2c)?;
+        match (
+            status.programming_mode,
+            status.error_detected,
+            status.checksum,
+        ) {
+            (false, _, _) => {
+                bootloader::enter_programming_mode(&i2c)?;
+                bootloader::check_programming_status(&i2c)?;
+            }
+            (true, true, _) => {
+                bootloader::leave_programming_mode(&i2c, false)?;
+                bootloader::enter_programming_mode(&i2c)?;
+                bootloader::check_programming_status(&i2c)?;
+            }
+            (true, false, 0) => {}
+            (true, false, _) => {
+                bootloader::reset_checksum(&i2c)?;
+            }
+        }
+        Ok::<_, Error>(())
+    })?;
+
+    let steady_duration = Duration::from_millis(50);
+    let memory_style = ProgressStyle::with_template(
+        "{spinner} [{prefix:1}] {msg:11} {wide_bar} {binary_bytes:>9}/{binary_total_bytes}",
+    )
+    .unwrap();
+    let total = firmware.total_bytes() as u64;
+
+    if !args.no_program {
+        let first_page = firmware.first_page();
+        let last_page = firmware.last_page();
+        let page_count = last_page - first_page + 1;
+        let bar = bars.add(msg(format!("Erasing {page_count}×1KiB flash pages")));
+        bar.enable_steady_tick(steady_duration);
+        check_success(&bar, || {
+            bootloader::erase_pages(&i2c, first_page, page_count)?;
+            bootloader::check_programming_status(&i2c)?;
+            Ok::<_, Error>(())
+        })?;
+        success(&bar);
+
+        let mut checksum = 0;
+        let bar = bars.add(
+            ProgressBar::new(total)
+                .with_message("Programming")
+                .with_style(memory_style.clone()),
+        );
+        bar.enable_steady_tick(steady_duration);
+        for segment in &firmware.segments {
+            if let Err(e) = bootloader::program_flash(&i2c, segment, &mut checksum, Some(&bar)) {
+                failure_msg(&bar, "Programming failed");
+                return Err(e.into());
+            }
+        }
+        success_msg(&bar, "Programmation successful");
+    }
+
+    if !args.no_verify {
+        let bar = bars.add(
+            ProgressBar::new(total)
+                .with_message("Verifying")
+                .with_style(memory_style),
+        );
+        for segment in &firmware.segments {
+            if let Err(e) = bootloader::verify_flash(&i2c, segment, Some(&bar)) {
+                failure_msg(&bar, "Verification failed");
+                return Err(e.into());
+            }
+        }
+        success_msg(&bar, "Verification successful");
+    }
+
+    if args.no_program {
+        let bar = bars.add(msg("Leaving programming mode"));
+        check_success(&bar, || bootloader::leave_programming_mode(&i2c, false))?;
+    } else {
+        let bar = bars.add(msg("Committing application"));
+        check_success(&bar, || bootloader::leave_programming_mode(&i2c, true))?;
+        let bar = bars.add(msg(String::from("Rebooting to firmware")));
+        check_success(&bar, || bootloader::reboot(&i2c))?;
+        thread::sleep(Duration::from_millis(100));
+        let bar = bars.add(msg("Checking running program"));
+        let program = check_success(&bar, || bootloader::active_program(&mut i2c))?;
+        bar.set_message(format!("Checking running program: {program}"));
+    }
+    Ok(())
+}
+
+fn cmd_status(args: StatusArgs) -> Result<()> {
+    let mut i2c = I2c::with_bus(args.i2c_bus)?;
+    println!("Running program: {}", bootloader::active_program(&mut i2c)?);
+    Ok(())
+}
diff --git a/i2c2-target/src/lib.rs b/i2c2-target/src/lib.rs
index 9a48baa68ca8f61fc03186118031f34e7ce666f2..ed7ebb96c4c3ec65f97ad88489d1269620eacad1 100644
--- a/i2c2-target/src/lib.rs
+++ b/i2c2-target/src/lib.rs
@@ -10,7 +10,7 @@ use embassy_stm32::{
 use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal};
 use heapless::Vec;
 
-pub const MESSAGE_SIZE: usize = 8;
+pub const MESSAGE_SIZE: usize = 10;
 
 pub type Callback<State> = &'static dyn Fn(&[u8], &mut Vec<u8, MESSAGE_SIZE>, &mut State);
 
diff --git a/support/.cargo/config.toml b/support/.cargo/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..b800c6d6c846a76d748bc2d605317e4725641c44
--- /dev/null
+++ b/support/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "thumbv7m-none-eabi"
diff --git a/support/Cargo.toml b/support/Cargo.toml
index 520c41487a657cc9e13b44c1f12087db870f01a8..8c5260ad4372a8934ff3964c21c84befc49fb260 100644
--- a/support/Cargo.toml
+++ b/support/Cargo.toml
@@ -4,8 +4,10 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
+bootloader-params = { path = "../bootloader-params" }
+cortex-m = { version = "0.7.7", features = ["critical-section-single-core", "inline-asm"] }
 defmt = "0.3.8"
 embassy-executor = { git = "https://github.com/embassy-rs/embassy", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
-embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["unstable-pac"] }
+embassy-stm32 = { git = "https://github.com/embassy-rs/embassy", features = ["stm32f103c8", "unstable-pac"] }
 embassy-sync = { git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
 embassy-time = { git = "https://github.com/embassy-rs/embassy", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
diff --git a/support/src/blinker.rs b/support/src/blinker.rs
index 4887e5266fb4cfb0531d60534c6925d1a902e009..0118243ba623cb1ea4f6fd6fdbfdd04471702515 100644
--- a/support/src/blinker.rs
+++ b/support/src/blinker.rs
@@ -22,7 +22,7 @@ pub async fn blink(pin: PB15, initial_pattern: &'static str) {
         for b in pattern.chars() {
             match b {
                 ' ' => {
-                    Timer::after_millis(200).await;
+                    Timer::after_millis(250).await;
                 }
                 '.' => {
                     led.toggle();
@@ -32,9 +32,9 @@ pub async fn blink(pin: PB15, initial_pattern: &'static str) {
                 }
                 '-' => {
                     led.toggle();
-                    Timer::after_millis(300).await;
+                    Timer::after_millis(350).await;
                     led.toggle();
-                    Timer::after_millis(200).await;
+                    Timer::after_millis(150).await;
                 }
                 _ => {
                     defmt::error!("unknown pattern letter {}", b);
diff --git a/support/src/boot_control.rs b/support/src/boot_control.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5ef7e8224388fbd8bba2413e4dd19b18ac6fa01c
--- /dev/null
+++ b/support/src/boot_control.rs
@@ -0,0 +1,102 @@
+use bootloader_params::{APPLICATION_SIZE, BOOTLOADER_SIZE, FLASH_START};
+use core::arch::asm;
+use embassy_stm32::pac::{self, bkp::Bkp};
+
+/// Those values are written in the first backup registers to indicate
+/// that the bootloader should stay in bootloader mode instead of
+/// launching the main program even if one can be found in flash.
+const BOOT_BOOTLOADER: [u16; 2] = [0x7531, 0x9783];
+
+/// Those values are written in the first backup registers to indicate
+/// that the bootloader should boot the application directly instead
+/// of making any choice. The values will be reset prior to jumping to
+/// the application.
+const BOOT_APPLICATION: [u16; 2] = [0x2705, 0x1972];
+
+/// Application magic written at the end of the memory to indicate the
+/// presence of an application.
+pub const APPLICATION_MAGIC: [u8; 4] = [0x59, 0x70, 0x24, 0x36];
+
+fn bkp() -> Bkp {
+    pac::RCC.apb1enr().modify(|w| {
+        w.set_pwren(true);
+        w.set_bkpen(true);
+    });
+    pac::PWR.cr().modify(|w| w.set_dbp(true));
+    pac::BKP
+}
+
+// Write data into the first backup registers
+fn write_backup_data(data: &[u16]) {
+    let bkp = bkp();
+    for (i, v) in data.iter().enumerate() {
+        // TODO: remove +1 when https://github.com/embassy-rs/stm32-data/pull/507 is merged
+        bkp.dr(i + 1).write(|w| w.set_d(*v))
+    }
+}
+
+// Check if the first backup registers contain data
+fn backup_data_present(data: &[u16]) -> bool {
+    let bkp = bkp();
+    // TODO: remove +1 when https://github.com/embassy-rs/stm32-data/pull/507 is merged
+    data.iter()
+        .enumerate()
+        .all(|(i, v)| bkp.dr(i + 1).read().d() == *v)
+}
+
+pub fn reboot() -> ! {
+    cortex_m::peripheral::SCB::sys_reset();
+}
+
+/// Reboot to bootloader
+pub fn reboot_to_bootloader() -> ! {
+    write_backup_data(&BOOT_BOOTLOADER);
+    reboot();
+}
+
+/// Next reboot will use automatic mode
+pub fn next_boot_is_auto() {
+    write_backup_data(&[0, 0]);
+}
+
+/// Reboot to the application
+pub fn reboot_to_application() -> ! {
+    write_backup_data(&BOOT_APPLICATION);
+    reboot();
+}
+
+pub fn is_bootloader_mode() -> bool {
+    backup_data_present(&BOOT_BOOTLOADER)
+}
+
+pub fn is_application_mode() -> bool {
+    backup_data_present(&BOOT_APPLICATION)
+}
+
+pub fn is_application_present() -> bool {
+    let magic_location = (FLASH_START + BOOTLOADER_SIZE + APPLICATION_SIZE) as *const [u8; 4];
+    unsafe { *magic_location == APPLICATION_MAGIC }
+}
+
+/// Jump to the application stored at [`BOOTLOADER_SIZE`] bytes after
+/// the beginning of the flash. `VTOR` will be set, then `SP` and `PC`
+/// will be updated.
+///
+/// # Note
+/// This will not deinitialize any peripheral, nor block any interrupt.
+///
+/// # Safety
+/// If no valid application is installed, the system might crash.
+pub unsafe fn jump_to_application() -> ! {
+    type VectorTablePtr = *const [usize; 2];
+    const VTOR: *mut VectorTablePtr = 0xE000_ED08 as *mut VectorTablePtr;
+    const VECTORS: VectorTablePtr = (FLASH_START + BOOTLOADER_SIZE) as VectorTablePtr;
+    *VTOR = VECTORS;
+    asm!(
+        "mov sp, {sp}",
+        "bx lr",
+        sp = in(reg) (*VECTORS)[0],
+        in("lr") (*VECTORS)[1],
+        options(noreturn, nostack)
+    );
+}
diff --git a/support/src/lib.rs b/support/src/lib.rs
index 1ab4f8bd46254430f891875d26bb45b2494bdf50..effe8f8fc75d920040b32d3d73019c33d6af71ab 100644
--- a/support/src/lib.rs
+++ b/support/src/lib.rs
@@ -3,6 +3,7 @@
 use embassy_stm32::pac;
 
 pub mod blinker;
+pub mod boot_control;
 pub mod power;
 
 pub fn mcu_kind() -> (&'static str, &'static str) {