Skip to main content

README.md

# 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>.