nerves_system_openwrt_one

Nerves system for the 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
RTC Yes (PCF8563 on I2C)
Watchdog Yes (SoC + GPIO)
OTA updates Yes - A/B slot, automatic rollback on failure

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

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:

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

Use the standard mix upload flow. The user app should alias upload to call this system's scripts/upload-ota.sh:

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

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

defp upload_ota(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

Then:

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:

To test the rollback path manually from a Nerves IEx shell:

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

This system ships its own backend at lib/nerves_system_openwrt_one/uboot_env_kv_backend.ex:

User apps wire it up in config/target.exs:

config :nerves_runtime,
  startup_guard_enabled: true,
  kv_backend: {NervesSystemOpenwrtOne.UBootEnvKVBackend, []}

And in rel/vm.args.eex:

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