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              |

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

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

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

```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
  `NervesSystemOpenwrtOne.UBootEnvKVBackend`), 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.

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

- **Reads** delegate to `UBootEnv.read/0` (plain `pread()` works fine
  on UBI volumes).
- **Writes** shell out to the C `fw_setenv -s <file>` tool, which
  issues `UBI_IOCVOLUP` for `/dev/ubi*` paths.

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

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

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