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 burnmix 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
- Power down the OpenWRT One.
- Plug the USB stick into the Type-A port (not Type-C).
- Move the boot switch to the NOR position.
- Hold the front panel button and apply power.
- Release the button when all front-panel LEDs turn off.
- Wait for the front LED to turn green (~30 seconds).
- Move the boot switch back to NAND.
- 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
resetOTA 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
endThen:
MIX_TARGET=openwrt_one mix upload <ip-or-hostname>
What upload-ota.sh does:
-
Builds a FIT image (
.itb) from the freshly-built.fwviawrap-firmware.sh(kernel + DTB + cpio.gz initramfs). -
SFTPs the
.itb, the slot-agnosticnerves_fw_*metadata, and a small Elixir apply script (apply-ota.exs) to the device. -
On the device, runs
apply-ota.exs, which:-
reads the current
nerves_fw_active(a or b), -
writes the new
.itbto the inactive slot's UBI volume viaubiupdatevol /dev/ubi0_3or/dev/ubi0_4, -
patches
<inactive>.nerves_fw_*, flipsnerves_fw_active, and setsupgrade_available=1+bootcount=0viafw_setenv, - reboots.
-
reads the current
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=1andbootcount=0along with the slot flip. -
On every boot while
upgrade_available=1, U-Boot'snerves_count_attemptscript bumpsbootcount. Once it exceedsbootlimit(default 3), it swapsnerves_fw_active, resets the counters, and boots the previous slot. -
Once the new firmware is healthy,
Nerves.Runtime.StartupGuardcallsNerves.Runtime.validate_firmware/0which clearsupgrade_available+bootcount(via the system'sNervesSystemOpenwrtOne.UBootEnvKVBackend), 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.
This system ships its own backend at
lib/nerves_system_openwrt_one/uboot_env_kv_backend.ex:
- Reads delegate to
UBootEnv.read/0(plainpread()works fine on UBI volumes). - Writes shell out to the C
fw_setenv -s <file>tool, which issuesUBI_IOCVOLUPfor/dev/ubi*paths.
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 600Recovery
If you ever wipe the ubi partition without including a fip volume,
BL2 cannot find U-Boot and the board hangs. Recovery procedure:
- Flip the NAND/NOR boot switch to NOR.
- Hold the front panel button while powering on.
- You'll land in the SPI NOR recovery U-Boot.
-
From there you can TFTP-boot the test FIT (
openwrt-one-initramfs.itb) or re-flash the full UBI image (openwrt-one-nand.ubi). - 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.