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

Two 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 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) |

`fwup` is required by Nerves itself; `mkimage` is required by this
system's `scripts/upload-ota.sh`. If either is missing, `mix firmware`
or `mix upload` errors with an explanatory message.

## 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: 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 2: 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). The user app
needs to alias `upload` to call this system's `scripts/upload-ota.sh`.

### Single-target firmware project

If your app only targets `:openwrt_one`:

```elixir
# In your project's mix.exs
def project do
  [..., aliases: aliases()]
end

defp aliases do
  [upload: ["firmware", &upload/1]]
end

defp upload(args) do
  target = List.first(args) || "nerves.local"
  fw = Path.join([Mix.Project.build_path(), "nerves", "images", "#{@app}.fw"])
  script = Path.join([File.cwd!(), "..", "nerves_system_openwrt_one",
                      "scripts", "upload-ota.sh"])
  case System.cmd(script, [fw, "root@#{target}"], into: IO.stream()) do
    {_, 0} -> :ok
    {_, code} -> Mix.raise("upload-ota.sh exited with #{code}")
  end
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 upload(args) do
  case Mix.target() do
    :openwrt_one ->
      target = List.first(args) || "nerves.local"
      fw = Path.join([Mix.Project.build_path(), "nerves", "images", "#{@app}.fw"])
      script = Path.join([File.cwd!(), "..", "nerves_system_openwrt_one",
                          "scripts", "upload-ota.sh"])
      case System.cmd(script, [fw, "root@#{target}"], into: IO.stream()) do
        {_, 0} -> :ok
        {_, code} -> Mix.raise("upload-ota.sh exited with #{code}")
      end

    _ ->
      Mix.Task.run("nerves.upload", args)
  end
end
```

Then:

```sh
MIX_TARGET=openwrt_one mix upload <ip-or-hostname>
```

What `upload-ota.sh` does:

1. Builds a FIT image (`.itb`) from the freshly-built `.fw` via
   `wrap-firmware.sh` (kernel + DTB + cpio.gz initramfs).
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>.