Elixir is a great choice language for IoT devices due to its fault-tolerant nature, but also because of the ability to handle a lot of concurrent I/O operations thanks to the BEAM scheduler running under the hood.

Table of contents

    Nerves is a platform that comes with a set of tooling that simplifies the process of creation and maintenance of an Elixir app on IoT devices. Software built using it are already battle-tested and working on production, so let's give it a try!

    Minimum requirements

    Nerves supports most common boards (for example, every type of Raspberry Pi), but there's one restriction: it has to be able to run Linux, that's because Nerves actually runs your Elixir program on a lightweight Linux distro. If you would like to run Elixir on an MCU instead, please check out AtomVM.

    Minimum requirements are as follows:

    • Hardware able to run Linux, preferably one included in the supported targets list
    • SD card with a minimum of 16 GB of capacity
    • SD card reader
    • Micro USB cable
    • Mac OS, Linux, or Windows installed

    Please be mindful, that some older USB cables don't come with data transfer lines and those are needed to communicate with the device.

    Configuration


    Disclaimer!

    This tutorial is being written for Mac OS and Raspberry Pi Zero 2 W target. The rest of the operating systems require other actions to be taken, so please follow the official installation guide.

    Installing dependencies and tools

    The easiest way to install dependencies is to use Homebrew. Just run the following:

    brew update
    brew install fwup squashfs coreutils xz pkg-config

    If you've installed Elixir or Erlang with Homebrew before, uninstall them in order to avoid clashes with ASDF installation:

    brew uninstall elixir
    brew uninstall erlang

    Next, install ASDF and finish the installation by adding sourcing of asdf.sh to your .zshrc file:

    git clone https://github.com/asdf-vm/asdf.git ~/.asdf
    echo "\n. $HOME/.asdf/asdf.sh" >> ~/.zshrc
    echo "\n. $HOME/.asdf/completions/asdf.bash" >> ~/.zshrc
    source ~/.zshrc

    Then, install Erlang and Elixir using ASDF.

    asdf plugin-add erlang
    asdf plugin-add elixir
    asdf install erlang 25.0.3
    asdf install elixir 1.14.0-otp-25
    asdf global erlang 25.0.3
    asdf global elixir 1.14.0-otp-25

    The last step is to install Nerves CLI tooling. In order to do that, update your versions of hex and rebar used by Elixir and install nerves_bootstrap archive.

    mix local.hex
    mix local.rebar
    mix archive.install hex nerves_bootstrap

    Creating a new project

    With the necessary tooling installed in the previous step, you can now create a new Nerves application:

    mix nerves.new hello_nerves

    Now take a look at the targets list and find your device's tag. I am using Raspberry Pi Zero 2 W, so my device's tag is rpi3a.

    Set an environmental variable named MIX_TARGET with your device's target, it will be used for configuring the right firmware.

    cd hello_nerves
    export MIX_TARGET=rpi3a # replace rpi3a with your device's tag
    mix deps.get

    While running mix deps.get I've encountered a problem with an unsupported Erlang version and I've had to update it to the newest one using asdf as shown in the previous step. This tutorial will eventually get outdated and such an issue will occur again.

    SSH keys

    If you don't have any keys in your ~/.ssh/ directory, make sure to generate one. They are needed to authenticate your device while connecting within the local network.

    Burning a firmware

    Now, plug in your SD card reader, and then build and burn your firmware. Nerves automatically discovers on which disk it has to be burned.

    mix firmware
    mix firmware.burn

    If you put the SD card back in your device and connect to it with the USB cable, you should be able to ping nerves.local. That's thanks to a service that handles Multicasting DNS (mDNS) running out-of-the-box on Mac OS.

    Setting up an Over-The-Air updating

    One of the best features of Nerves is the ease of over-the-air firmware updating - with just a single command you can push new firmware to a device in the same network.

    There's also an external open-source solution called Nerves Hub that provides a web interface for managing updates. Unfortunately, while this post is being written the creation of new public accounts is disabled, of course, you can host your own Nerves Hub instance if you wish, but I won't be describing that process.

    Connecting the device to the WiFi

    Let's configure the device to work in the local network.

    Add a WiFi configuration to the :vintage_net config list in config/target.exs:

    diff --git a/config/target.exs b/config/target.exs
    index b06b6df..56bf4d9 100644
    --- a/config/target.exs
    +++ b/config/target.exs
    @@ -1,4 +1,5 @@
     import Config
    +import System
    
     # Use shoehorn to start the main application. See the shoehorn
     # library documentation for more control in ordering how OTP
    @@ -50,7 +51,18 @@ config :vintage_net,
            type: VintageNetEthernet,
            ipv4: %{method: :dhcp}
          }},
    -    {"wlan0", %{type: VintageNetWiFi}}
    +    {"wlan0",
    +     %{
    +       type: VintageNet.Technology.WiFi,
    +       wifi: %{
    +         key_mgmt: System.get_env("WIFI_KEY_MANAGEMENT", "wpa_psk") |> String.to_atom(),
    +         ssid: System.fetch_env!("WIFI_SSID"),
    +         psk: System.fetch_env!("WIFI_PASSWORD")
    +       },
    +       ipv4: %{
    +         method: :dhcp
    +       }
    +     }}
       ]
    
     config :mdns_lite,

    Now, create the necessary environment variables:

    export WIFI_SSID=your_wifi_name
    export WIFI_PASSWORD=your_wifi_password

    After burning a new firmware, you will be able to ping nerves.local from your Mac device in the local network without connecting the USB cable.

    Uploading a new firmware

    If you upload for the first time, connect to your device via SSH in order to generate a new fingerprint and to make sure that it's available in the network.

    ssh nerves.local

    Now you can use mix upload to upload your firmware over the air:

    mix upload

    screenshot of the terminal with a successful firmware upload

    Troubleshooting

    If you encounter a problem with authorization, remove the old key, burn your firmware again and follow the steps described in the point above.

    ssh-keygen -R nerves.local

    Adding web interface with Phoenix

    The poncho project structure

    If you are familiar with the umbrella project structure, the poncho is very similar but it gives you greater control over a configuration order. In short words, instead of using the in_umbrella option, you are adding submodules as ordinary deps in the main service.

    Nerves supports both structures, but the poncho is preferred.

    Let's move a project directory into a containing folder and create a new Phoenix application named hello_nerves_ui:

    cd ..
    mkdir hello_nerves_container
    mv hello_nerves hello_nerves_container
    cd hello_nerves_container
    mix archive.install hex phx_new
    mix phx.new hello_nerves_ui --no-ecto --no-mailer

    Changing hello_nerves_ui

    Go into hello_nerves_ui directory and modify esbuild to run only on the host:

    diff --git a/mix.exs b/mix.exs
    index 07901b2..546a12f 100644
    --- a/mix.exs
    +++ b/mix.exs
    @@ -39,7 +39,7 @@ defmodule HelloNervesUi.MixProject do
           {:phoenix_live_view, "~> 0.17.5"},
           {:floki, ">= 0.30.0", only: :test},
           {:phoenix_live_dashboard, "~> 0.6"},
    -      {:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
    +      {:esbuild, "~> 0.4", runtime: Mix.env() == :dev && Mix.target() == :host},
           {:telemetry_metrics, "~> 0.6"},
           {:telemetry_poller, "~> 1.0"},
           {:gettext, "~> 0.18"},

    Now modify config/prod.exs and set it to listen on port 80 and change host to nerves.local. You also need to set the start option to true , because the server isn't started through mix :

    diff --git a/config/prod.exs b/config/prod.exs
    index 364c6f7..6dd281a 100644
    --- a/config/prod.exs
    +++ b/config/prod.exs
    @@ -9,7 +9,22 @@ import Config
     # manifest is generated by the `mix phx.digest` task,
     # which you should run after static files are built and
     # before starting your production server.
    -config :hello_nerves_ui, HelloNervesUiWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
    +config :hello_nerves_ui, HelloNervesUiWeb.Endpoint,
    +  url: [host: "nerves.local"],
    +  http: [port: 80],
    +  cache_static_manifest: "priv/static/cache_manifest.json",
    +  secret_key_base: "HEY05EB1dFVSu6KykKHuS4rQPQzSHv4F7mGVB/gnDLrIu75wE/ytBXy2TaL3A6RA",
    +  live_view: [signing_salt: "AAAABjEyERMkxgDh"],
    +  check_origin: false,
    +  render_errors: [view: HelloNervesUiWeb.ErrorView, accepts: ~w(html json), layout: false],
    +  pubsub_server: Ui.PubSub,
    +  # Start the server since we're running in a release instead of through `mix`
    +  server: true,
    +  # Nerves root filesystem is read-only, so disable the code reloader
    +  code_reloader: false
    +
    +# Use Jason for JSON parsing in Phoenix
    +config :phoenix, :json_library, Jason
    
     # Do not print debug messages in production
     config :logger, level: :info

    Changing hello_nerves

    In the main project, add hello_nerves_ui dependency:

    diff --git a/mix.exs b/mix.exs
    index 873d33c..30b9ee4 100644
    --- a/mix.exs
    +++ b/mix.exs
    @@ -55,7 +55,8 @@ defmodule HelloNerves.MixProject do
           {:nerves_system_bbb, "~> 2.14", runtime: false, targets: :bbb},
           {:nerves_system_osd32mp1, "~> 0.10", runtime: false, targets: :osd32mp1},
           {:nerves_system_x86_64, "~> 1.19", runtime: false, targets: :x86_64},
    -      {:nerves_system_grisp2, "~> 0.3", runtime: false, targets: :grisp2}
    +      {:nerves_system_grisp2, "~> 0.3", runtime: false, targets: :grisp2},
    +      {:hello_nerves_ui, path: "../hello_nerves_ui", targets: @all_targets, env: Mix.env()}
         ]
       end

    The last step is to import the configuration file from hello_nerves_ui into the main service's config/target.exs.

    diff --git a/config/target.exs b/config/target.exs
    index 56bf4d9..d0b1771 100644
    --- a/config/target.exs
    +++ b/config/target.exs
    @@ -101,3 +101,5 @@ config :mdns_lite,
     # Uncomment to use target specific configurations
    
     # import_config "#{Mix.target()}.exs"
    +
    +import_config "../../hello_nerves_ui/config/prod.exs"

    Deploying

    When changes are made, you can finally build and upload your firmware to the device.

    mix firmware
    mix upload

    Let's open the browser and go to nerves.local:

    screenshot of the browser with a successful app deployment

    Everything works as expected!

    Other UI libraries

    If you are interested in drawing directly on screen, for example on Raspberry Pi Touch Display or HDMI-connected one, check out Scenic library.

    The full list of UI libraries can be found on the official Nerves page.

    FAQ

    What are the minimum requirements for programming an IoT device with Elixir using Nerves?

    To program an IoT device with Elixir using Nerves, you need hardware capable of running Linux, a 16 GB SD card, an SD card reader, and a micro USB cable. The device must be supported by Nerves.

    How do you set up the development environment for Nerves on MacOS?

    Install dependencies using Homebrew, uninstall any existing Elixir or Erlang, install ASDF, and use it to install specific Erlang and Elixir versions. Lastly, install the Nerves CLI tooling.

    How do you create a new Nerves project for a specific device like Raspberry Pi Zero 2 W?

    Create a new project with mix nerves.new, set the MIX_TARGET for your device, and run mix deps.get to fetch dependencies.

    How can you set up your IoT device to connect to Wi-Fi using Nerves?

    Modify the :vintage_net configuration in config/target.exs to include your Wi-Fi credentials and ensure the device connects to the local network.

    What is the process for burning and uploading firmware to the IoT device using Nerves?

    Build the firmware with mix firmware, burn it onto an SD card, and use mix firmware.burn. To upload new firmware over the air, use mix upload.

    How can you add a web interface to your Nerves project using Phoenix?

    Create a new Phoenix project within the Nerves project structure, configure it for Nerves, and set it up to run on the device.

    What are the recommended project structures when combining Nerves and Phoenix for IoT applications?

    The recommended structure is a poncho project, which is similar to the umbrella structure but offers greater control over configuration order. Integrate Phoenix as a separate application within the Nerves project.

    How do you deploy changes to your IoT device running Nerves?

    After making changes, build the new firmware with mix firmware and upload it to the device with mix upload. Access the device's web interface by navigating to nerves.local.

    Artur Ziętkiewicz
    Artur Ziętkiewicz Elixir & React Developer

    Read more
    on #curiosum blog