nerves_system_openwrt_one

Copy Markdown View Source

Nerves system for the OpenWRT One router based on the MediaTek MT7981B (Filogic 820).

FeatureDescription
CPU2x ARM Cortex-A53 @ 1.3 GHz
Memory1 GB DDR4
Storage256 MiB SPI NAND (Winbond) + 4 MiB SPI NOR
WiFiMT7976C dual-band WiFi 6
Ethernet2.5 GbE WAN (Airoha EN8811H) + 1 GbE LAN (internal PHY)
Linux kernel6.18 mainline + small patches (mtk_bmt + OpenWrt SPI cal)
IEx terminalUART0 via front USB-C console port (115200 8N1, no adapter)
GPIO, I2C, SPIYes - Elixir Circuits
RTCYes (PCF8563 on I2C)
WatchdogYes (SoC + GPIO)
OTA updatesYes - 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):

ToolWhat it's used forInstall
fwupBuilding the .fw (mix firmware, mix burn)macOS: brew install fwup   Linux: apt install fwup (or the deb from fwup-home)
mkimageWrapping the kernel into a FIT image at burn/OTA timemacOS: brew install u-boot-tools   Linux: apt install u-boot-tools
dtcInvoked internally by mkimage to compile the FITmacOS: brew install dtc   Linux: apt install device-tree-compiler (auto-pulled by u-boot-tools deb)
ubinizeBuilding the multi-volume UBI in mix openwrt_one.firmware_postLinux: apt install mtd-utils   macOS: no Homebrew package — use Linux or build from source
b2sumBLAKE2b-256 hashing in mix openwrt_one.firmware_post's spliceLinux: 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:

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

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). 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:

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

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:

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

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:

  • 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:

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

{:nerves_uboot_env_ubi, "~> 0.1"}

In config/target.exs:

config :nerves_runtime,
  startup_guard_enabled: true,
  kv_backend: {Nerves.Runtime.KVBackend.UBootEnvUBI, []}

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.