# nerves_system_openwrt_one
[Nerves](https://nerves-project.org/) system for the
[OpenWRT One](https://openwrt.org/toh/openwrt/one) router based on the
MediaTek MT7981B (Filogic 820).
| Feature | Description |
| -------------- | ---------------------------------------------------------- |
| CPU | 2x ARM Cortex-A53 @ 1.3 GHz |
| Memory | 1 GB DDR4 |
| Storage | 256 MiB SPI NAND (Winbond) + 4 MiB SPI NOR |
| WiFi | MT7976C dual-band WiFi 6 |
| Ethernet | 2.5 GbE WAN (Airoha EN8811H) + 1 GbE LAN (internal PHY) |
| Linux kernel | 6.18 mainline + small patches (mtk_bmt + OpenWrt SPI cal) |
| IEx terminal | UART0 via front USB-C console port (115200 8N1, no adapter)|
| GPIO, I2C, SPI | Yes - [Elixir Circuits](https://github.com/elixir-circuits)|
| RTC | Yes (PCF8563 on I2C) |
| Watchdog | Yes (SoC + GPIO) |
| OTA updates | Yes - A/B slot, automatic rollback on failure |
## Host prerequisites
These CLI tools must be on your PATH on the dev machine in addition to
the standard Nerves stack (Erlang, Elixir, mix):
| Tool | What it's used for | Install |
| ------------ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `fwup` | Building the `.fw` (`mix firmware`, `mix burn`) | macOS: `brew install fwup` Linux: `apt install fwup` (or the deb from fwup-home) |
| `mkimage` | Wrapping the kernel into a FIT image at burn/OTA time | macOS: `brew install u-boot-tools` Linux: `apt install u-boot-tools` |
| `dtc` | Invoked internally by `mkimage` to compile the FIT | macOS: `brew install dtc` Linux: `apt install device-tree-compiler` (auto-pulled by u-boot-tools deb) |
| `ubinize` | Building the multi-volume UBI in `mix openwrt_one.firmware_post`| Linux: `apt install mtd-utils` macOS: no Homebrew package — use Linux or build from source |
| `b2sum` | BLAKE2b-256 hashing in `mix openwrt_one.firmware_post`'s splice | Linux: `apt install coreutils` (preinstalled) macOS: `brew install coreutils` (binary is `gb2sum`) |
`fwup` is required by Nerves itself; `mkimage` and `ubinize` are
required by this system's recovery / OTA tooling. Each tool's absence
is reported with a pointer to the install command.
## Boot chain
```
BL2 (SPI NOR "bl2-nor")
-> reads UBI volume "fip" from SPI NAND
-> loads BL31 + U-Boot proper
U-Boot
-> reads UBI volume "fit_${nerves_fw_active}" (= fit_a or fit_b)
-> bootm config-1 -> Linux + Nerves
```
The `fip` volume holds OpenWrt's pre-built ARM Trusted Firmware FIP. We
don't build it ourselves (it requires the MediaTek ATF fork) — see
`prebuilt/openwrt-one-fip.bin` and `prebuilt/README.md`.
## UBI layout on SPI NAND
```
vol_id name type size purpose
0 ubootenv dynamic 128 KiB U-Boot env (redundant copy A)
1 ubootenv2 dynamic 128 KiB U-Boot env (redundant copy B)
2 fip static ~1 MiB BL31 + U-Boot proper
3 fit_a dynamic 50 MiB kernel + initramfs, slot A
4 fit_b dynamic 50 MiB kernel + initramfs, slot B
5 rootfs_data dynamic rest OpenWrt-style data partition
```
The U-Boot environment baked into `ubootenv*` is the **full** OpenWrt
24.10 default env (`bootcmd`, `boot_*`, `ubi_*`, `bootmenu_*`) plus a
small Nerves overlay (`boot_active_slot`, `nerves_swap_active`,
`nerves_pre_boot`, `nerves_count_attempt`, `bootlimit`, slot-prefixed
`nerves_fw_*` metadata). See `prebuilt/uboot-env-template.txt`.
## Initial install (one-time, blank board)
The very first install uses the OpenWRT One's **NOR full-recovery
mode** — an independent U-Boot in SPI NOR that loads firmware from a
FAT-formatted USB stick and reflashes the entire SPI NAND. No serial
console, no TFTP server, no typing of U-Boot commands.
### Step 1: wire up the firmware-post alias (one-time, per project)
Add this alias to your firmware project's `mix.exs` so every
`mix firmware` automatically produces a `.fw` with the user-merged
initramfs/UBI baked in:
```elixir
defp aliases do
[
# ...
firmware: ["firmware", "openwrt_one.firmware_post"]
]
end
```
Without this, the `.fw` ships the stale system-only
`data/openwrt-one-initramfs.itb` and `data/openwrt-one-nand.ubi`
that were assembled at *system* build time — before your release was
merged in. The board boots but erlinit aborts with `No release found
in /srv/erlang.` See `Mix.Tasks.OpenwrtOne.FirmwarePost` for the
full story.
### Step 2: build the firmware and prepare a USB stick
```sh
MIX_TARGET=openwrt_one mix firmware
MIX_TARGET=openwrt_one mix burn
```
`mix burn` detects attached removable drives and prompts you to pick
one. It partitions the stick with a small FAT32 volume and writes two
files onto it:
- `openwrt-mediatek-filogic-openwrt_one-snand-preloader.bin` (BL2)
- `openwrt-mediatek-filogic-openwrt_one-factory.ubi` (our full
multi-volume UBI image with fip + fit_a + fit_b + ubootenv + rootfs_data)
### Step 3: flash the device
1. Power down the OpenWRT One.
2. Plug the USB stick into the **Type-A** port (not Type-C).
3. Move the **boot switch to the NOR** position.
4. Hold the **front panel button** and apply power.
5. Release the button when all front-panel LEDs turn off.
6. Wait for the front LED to turn **green** (~30 seconds).
7. Move the boot switch back to **NAND**.
8. Power-cycle the device. It will autoboot Nerves.
### Alternative: serial + TFTP
The OpenWRT One has a built-in USB-to-serial converter on the front
USB-C port — just plug a USB-C cable, no external UART adapter needed
(`/dev/cu.usbmodem0001` on macOS, `/dev/ttyACM0` on Linux). If you
also have a TFTP server on the network, you can flash from the
U-Boot prompt (115200 8N1):
```
setenv ipaddr 192.168.X.Y
setenv serverip 192.168.X.Z
tftpboot $loadaddr openwrt-one-nand.ubi
ubi detach
mtd erase ubi
mtd write spi-nand0 $loadaddr 0x100000 $filesize
reset
```
## OTA updates
`mix upload` doesn't work out of the box — stock fwup can't write to
UBI volumes (see "Why not stock fwup tasks?" below). This system
provides `Mix.Tasks.OpenwrtOne.Upload`, which the user app wires
into the `upload` lifecycle via a mix alias.
### Single-target firmware project
If your app only targets `:openwrt_one`, alias `upload` straight to
the system task:
```elixir
defp aliases do
[
firmware: ["firmware", "openwrt_one.firmware_post"],
upload: ["firmware", "openwrt_one.upload"]
]
end
```
### Multi-target firmware project
If your app supports multiple Nerves systems (e.g. one binary
shared across openwrt_one, rpi5, x86_64 targets), dispatch on
`Mix.target/0` so non-openwrt_one targets keep using stock Nerves
upload:
```elixir
defp aliases do
[
firmware: ["firmware", "openwrt_one.firmware_post"],
upload: ["firmware", &dispatch_upload/1]
]
end
defp dispatch_upload(args) do
case Mix.target() do
:openwrt_one -> Mix.Task.run("openwrt_one.upload", args)
_ -> Mix.Task.run("nerves.upload", args)
end
end
```
Both `firmware_post` and `openwrt_one.upload` raise when invoked
with `MIX_TARGET != :openwrt_one`, so the dispatcher is required
whenever you build firmware for non-openwrt_one targets from the
same project.
Then:
```sh
MIX_TARGET=openwrt_one mix upload <ip-or-hostname>
```
The destination host is taken from the first arg, falling back to
`$MIX_TARGET_HOST` and then `nerves.local`. The `root@` prefix is
added automatically.
What `upload-ota.sh` does:
1. Extracts `data/openwrt-one-initramfs.itb` straight out of the
freshly-built `.fw`. The `firmware: ["firmware",
"openwrt_one.firmware_post"]` alias makes sure this resource is
the user-merged FIT image, not the system-only one.
2. SFTPs the `.itb`, the slot-agnostic `nerves_fw_*` metadata, and a
small Elixir apply script (`apply-ota.exs`) to the device.
3. On the device, runs `apply-ota.exs`, which:
- reads the current `nerves_fw_active` (a or b),
- writes the new `.itb` to the **inactive** slot's UBI volume via
`ubiupdatevol /dev/ubi0_3` or `/dev/ubi0_4`,
- patches `<inactive>.nerves_fw_*`, flips `nerves_fw_active`, and
sets `upgrade_available=1` + `bootcount=0` via `fw_setenv`,
- reboots.
The whole round trip (rebuild + SFTP + apply + reboot + heartbeat) is
typically ~30 seconds. No NAND wipe; the previous slot stays intact
for rollback.
### Why not stock fwup tasks?
`fwup`'s on-device actions (`raw_write`, `path_write`, `pipe_write`)
all use `pwrite()`, which UBI rejects with `EPERM` because the volume
needs `UBI_IOCVOLUP` to enter atomic-update mode first. The C tools
`ubiupdatevol` and `fw_setenv` issue that ioctl transparently for
`/dev/ubi*` paths; fwup doesn't.
## A/B slot rollback
The system supports two complementary kinds of rollback:
### Image-level (immediate)
If U-Boot's `ubi read` or `bootm` fails on the active slot (corrupt
FIT, empty volume, bad image header), the `boot_production` script
flips `nerves_fw_active`, runs `saveenv`, and retries with the other
slot. The demoted slot stays in env until the next OTA replaces it.
### Runtime-level (bootcount)
If the kernel boots cleanly but the application fails to come up
healthy, U-Boot uses the standard
[U-Boot bootcount convention](https://docs.u-boot.org/en/latest/usage/environment.html#bootcount-bootlimit-altbootcmd):
- OTA sets `upgrade_available=1` and `bootcount=0` along with the slot
flip.
- On every boot while `upgrade_available=1`, U-Boot's
`nerves_count_attempt` script bumps `bootcount`. Once it exceeds
`bootlimit` (default 3), it swaps `nerves_fw_active`, resets the
counters, and boots the previous slot.
- Once the new firmware is healthy,
`Nerves.Runtime.StartupGuard` calls `Nerves.Runtime.validate_firmware/0`
which clears `upgrade_available` + `bootcount` (via the system's
`Nerves.Runtime.KVBackend.UBootEnvUBI` from the `nerves_uboot_env_ubi`
package), locking in the new slot.
To test the rollback path manually from a Nerves IEx shell:
```elixir
# Simulate "boot validated by app" never happening:
System.cmd("/usr/sbin/fw_setenv", ["upgrade_available", "1"])
System.cmd("/usr/sbin/fw_setenv", ["bootcount", "4"]) # bootlimit + 1
Nerves.Runtime.reboot()
# After reboot, you should be on the other slot.
```
## Runtime KV backend
Writing to a `/dev/ubi*` character device requires the
`UBI_IOCVOLUP` ioctl, so the default `Nerves.Runtime.KVBackend.UBootEnv`
(which uses the Erlang `uboot_env` library and plain `pwrite()`)
returns `{:error, :eperm}` from `Nerves.Runtime.KV.put/1`. That breaks
`Nerves.Runtime.validate_firmware/0` and the whole `StartupGuard`
chain.
Use the
[nerves_uboot_env_ubi](https://github.com/Hermanverschooten/nerves_uboot_env_ubi)
package, which reads via `UBootEnv.read/0` (plain `pread()` works
fine on UBI volumes) and writes by shelling out to `fw_setenv` (which
issues `UBI_IOCVOLUP` transparently).
In your firmware app's `mix.exs`:
```elixir
{:nerves_uboot_env_ubi, "~> 0.1"}
```
In `config/target.exs`:
```elixir
config :nerves_runtime,
startup_guard_enabled: true,
kv_backend: {Nerves.Runtime.KVBackend.UBootEnvUBI, []}
```
And in `rel/vm.args.eex`:
```text
## Require StartupGuard's heart callback to register within 10 minutes,
## otherwise heart triggers a reboot and U-Boot bumps bootcount.
-env HEART_INIT_TIMEOUT 600
```
## Recovery
If you ever wipe the `ubi` partition without including a `fip` volume,
BL2 cannot find U-Boot and the board hangs. Recovery procedure:
1. Flip the **NAND/NOR boot switch to NOR**.
2. Hold the **front panel button** while powering on.
3. You'll land in the SPI NOR recovery U-Boot.
4. From there you can TFTP-boot the test FIT (`openwrt-one-initramfs.itb`)
or re-flash the full UBI image (`openwrt-one-nand.ubi`).
5. Flip the boot switch back to NAND, power-cycle.
## Support
This is an unofficial Nerves system, not part of `nerves-project`.
Patches and issues welcome at
<https://github.com/Hermanverschooten/nerves_system_openwrt_one>.