From mix nerves.new to an ExRatatui app on a Raspberry Pi's display, with a USB keyboard and the touch panel. The same steps fit any Nerves target whose display is a Linux framebuffer. The rpi_framebuffer example is this guide, built.

The project

mix nerves.new my_device

Two dependencies in mix.exs. input_event reads the keyboard and the touch panel; it is a C port that only builds on Linux, so it is the project's dependency, not the library's.

{:raster_ex_ratatui, "~> 0.4"},
{:input_event, "~> 1.4", targets: @all_targets}

The app is a plain use ExRatatui.App module, built and tried in a terminal on the host first. On the panel it runs through a surface:

defmodule MyDevice.Surface do
  use RasterExRatatui.Framebuffer.Surface, app: MyDevice.Dashboard
end

The surface only belongs on the device, so the application starts it when the target config has an entry for it. It goes last among the children: its start waits for the display driver, up to thirty seconds.

def start(_type, _args) do
  children =
    case Application.fetch_env(:my_device, MyDevice.Surface) do
      {:ok, opts} -> [{MyDevice.Surface, Keyword.put_new(opts, :name, MyDevice.Surface)}]
      :error -> []
    end

  Supervisor.start_link(children, strategy: :one_for_one, name: MyDevice.Supervisor)
end

And in config/target.exs:

config :my_device, MyDevice.Surface, rotate: 90, touch: true

# IEx on the serial port instead of the panel; the surface detaches the kernel's console itself.
config :nerves, :erlinit, ctty: "ttyS0"

Nothing there describes the panel: its size and depth are read from sysfs at boot, the pixel format and font scale follow from them. The option table is in RasterExRatatui.Framebuffer.Surface.

Build and burn

export MIX_TARGET=rpi4
mix deps.get
mix firmware
mix burn               # the first time; later: mix upload nerves.local

Two things get in the way:

  • The Elixir must be built for the system's OTP. elixir --version says "compiled with Erlang/OTP N", and N must be the OTP major of the Nerves system (28 for nerves_system_rpi4 2.0.x). A version manager's plain elixir 1.x is often an older-OTP build; with mise, mise use erlang@28 elixir@1.19.4-otp-28 in the project, then mix local.hex, mix local.rebar, and mix archive.install hex nerves_bootstrap for that install.
  • EX_RATATUI_BUILD set in the shell builds the NIF from source for the host; unset it for firmware builds.

A panel on its side, and touch

/dev/fb0 always has the panel's native orientation. rotate: 90 (or 270, whichever puts the text the right way up) turns the app; touch follows the rotation by itself. If taps land mirrored or on the wrong axis, invert_x: true, invert_y: true, or swap_xy: true in the same config fix it.

First checks over SSH

RasterExRatatui.Framebuffer.info("fb0")
#=> {:ok, %{width: 720, height: 1280, bits_per_pixel: 16, stride: 1440}}

# Paint it red (16 bits per pixel; at 32 use <<0, 0, 255, 255>>)
File.write!("/dev/fb0", :binary.copy(<<0x00, 0xF8>>, 720 * 1280))

InputEvent.enumerate()
RingLogger.next()
RasterExRatatui.Telemetry.probe(MyDevice.Surface, 10)

The surface logs the geometry, format, and scale it chose, and each input device it reads. The probe says whether the panel keeps up: raster plus push bounds the frame rate, and a mailbox that grows means the surface is falling behind.

When it does not work

  • The panel stays dark, SSH works, and the log shows {:framebuffer, {:enoent, ...}}. No framebuffer appeared within framebuffer_timeout:. The Raspberry Pi Touch Display 2 does not come up on the 2.1.x Pi systems: their kernel lacks CONFIG_BACKLIGHT_PWM, which its overlay needs since Linux 6.18. Stay on ~> 2.0.0 for it; HDMI is not affected.
  • A cursor or boot text over the app. The console unbind failed or the framebuffer console is not vtcon1: cat /sys/class/vtconsole/*/name tells which one is the "frame buffer device", and console: takes its name.
  • No keys. InputEvent.enumerate/0 should list a device whose :ev_key report includes :key_a. Wireless keyboards behind a receiver can show up as several devices; the surface takes the first that matches, and keyboard: "/dev/input/eventN" picks one.
  • A 3D object or an image turns slowly. A pixel region costs time per pixel every time it changes, on a rotated panel as on a flat one. A slower animation tick or a smaller region is the fix; Telemetry.probe/3 shows the cost.
  • Under-voltage detected in dmesg. A Pi with a display draws more than a laptop's USB port gives; use a wall supply.

When the defaults do not fit

Each of init/1, push/2, handle_info/2, and terminate/2 can be overridden and call the default (Linux Framebuffers). A panel that is not a framebuffer gets its own surface (Building a Surface), and a device that already has a process in charge of its display runs a RasterExRatatui.Session from it.