Init 1

Exploring nerves on an RPI4

posted on 2024 Apr 12

Small dump of a tiny presentation I made for a rather small audience on Elixir Nerves and Phoenix liveview. Can be followed as a mini tutorial step by step to get an IoT system running a livebook with a small application backed by Phoenix.

Requirements

Hardware

  • Raspberry PI 4 Model The B
  • Micro SD

Software dependencies

Touched arguments

  • Little history, development
  • Docs “Getting started” and the actual procedure to burn a firmware
  • Burn firmware into RPI4 Model B
  • Connect to livebook on http://nerves.local
    • Walk through some examples
  • Connect through ssh by ssh nerves.local
    • h Toolshed
    • weather/0 :)
  • Try MQTT communication
    • Mix.install is not yet supported
    • mix archive.install hex nerves_bootstrap
  • Something better: Phoenix LiveView on the board
    • Poncho Project
    • Small GenServer to publish data periodically
    • ssh into the board, log_attach
  • Nerves HUB
  • Peridio

Quickstart

Check nerves, the getting started link points directly to the simplest example: running livebook on embedded systems

  • fwup as the quickest setup.
  • exploring some pre-loaded samples
  • check the same commands through an ssh session: ssh nerves.local pw: nerves
    • h Toolshed
    • log_attach to attach to the Elixir Logger (useful to debug running apps)
    • weather :)
  • test communication with a simple snippet

Testing MQTT communication, setup:

Run an MQTT broker on the host machine, let’s use mosquitto (if an Address not available error happens, use the following conf)

persistence false
log_dest file /mosquitto/log/mosquitto.log
log_dest stdout
listener 1883
allow_anonymous true

Finally run a mosquitto container mounting the local config

docker run -it -p 1883:1883 -v $PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf eclipse-mosquitto

Create a new livebook and on the setup paste this dependency

Mix.install([{:emqtt, github: "emqx/emqtt", tag: "1.4.4", system_env: [{"BUILD_WITHOUT_QUIC", "1"}]}])

Publish random temperature values in a subsequent code block

client_id = "weather_sensor"
report_topic = "reports/#{client_id}/temperature"
host = '192.168.10.12' # This should point to the local machine address, where our MQTT broker is listening
port = 1883

emqtt_opts = %{
  host: host,
  port: port,
  clean_start: false,
  name: :emqtt
}

{:ok, pid} = :emqtt.start_link(emqtt_opts)
{:ok, _} = :emqtt.connect(pid)

temperature = 10.0 + 2.0 * :rand.normal()
message = %{"timestamp" => System.system_time(:millisecond), "temperature" => temperature}
payload = Jason.encode!(message)
:emqtt.publish(pid, report_topic, payload)
:emqtt.stop(pid)

Oh no, must rebuild the livebook :( one current limitation of livebook in nerves is that Mix.install is not supported yet. Livebook can be customized easily on the host machine.

Flash nerves_livebook into device (rpi4)

git clone https://github.com/livebook-dev/nerves_livebook.git
cd nerves_livebook

Add dependency to the mix.exs


mix.exs


defp deps do
    [
      ...,
      {:emqtt, github: "emqx/emqtt", tag: "1.4.4", system_env: [{"BUILD_WITHOUT_QUIC", "1"}]}
      ...
    ]
end

Build the firmware and push it to the device

# Set the MIX_TARGET to the desired platform (rpi0, bbb, rpi3, etc.)
export MIX_TARGET=rpi4
mix deps.get
mix firmware

# Option 1: Insert a MicroSD card
mix burn

# Option 2: Upload to an existing Nerves Livebook device
mix firmware.gen.script
./upload.sh livebook@nerves.local

Now the previous snippet should work correctly and we should see the message published onto our MQTT broker.

Daring something more

Let’s expand the previous snippet: a GenServer perpetually generating temperature values and also accepting command from the broker

defmodule WeatherSensor do
  @moduledoc false
  use GenServer

  @client_id "weather_sensor"
  @report_topic "reports/#{@client_id}/temperature"
  @host '192.168.10.12' # This should point to the local machine address, where our MQTT broker is listening
  @port 1883
  @interval_ms 2000

  def start_link([]) do
    GenServer.start_link(__MODULE__, [])
  end

  def init([]) do
    {:ok, pid} = :emqtt.start_link(emqtt_opts())

    state = %{
      interval: @interval_ms,
      timer: nil,
      report_topic: @report_topic,
      pid: pid
    }

    {:ok, set_timer(state), {:continue, :start_emqtt}}
  end

  defp emqtt_opts,
    do: %{
      host: @host,
      port: @port,
      clean_start: false,
      name: :emqtt
    }

  def handle_continue(:start_emqtt, %{pid: pid} = state) do
    {:ok, _} = :emqtt.connect(pid)

    {:ok, _, _} = :emqtt.subscribe(pid, {"commands/#{@client_id}/set_interval", 1})
    {:noreply, state}
  end

  def handle_info(:tick, %{report_topic: topic, pid: pid} = state) do
    report_temperature(pid, topic)
    {:noreply, set_timer(state)}
  end

  def handle_info({:publish, publish}, state) do
    handle_publish(parse_topic(publish), publish, state)
  end

  defp handle_publish(["commands", _, "set_interval"], %{payload: payload}, state) do
    {:noreply, set_timer(%{state | interval: String.to_integer(payload)})}
  end

  defp handle_publish(_, _, state) do
    {:noreply, state}
  end

  defp parse_topic(%{topic: topic}) do
    String.split(topic, "/", trim: true)
  end

  defp set_timer(state) do
    if state.timer do
      Process.cancel_timer(state.timer)
    end

    timer = Process.send_after(self(), :tick, state.interval)
    %{state | timer: timer}
  end

  defp report_temperature(pid, topic) do
    temperature = 10.0 + 2.0 * :rand.normal()
    message = %{"timestamp" => System.system_time(:millisecond), "temperature" => temperature}
    payload = Jason.encode!(message)
    :emqtt.publish(pid, topic, payload)
  end
end

On a subsequent block, we can run the GenServer

{:ok, pid} = WeatherSensor.start_link([])

On the host we should see data coming every ~2s, we can confirm it by peeking into the mosquitto logs or by subscribing to the topic on localhost

mosquitto_sub -t reports/weather_sensor/temperature -h 127.0.0.1

Phoenix liveview

Let’s build a simple Phoenix app to subscribe to the topic and see real-time data coming through LiveView. Let’s not forget --no-ecto we don’t need DBs here, --no-mailer, --no-gettext, --no-dashboard, and --live. This should cut out a fair amount of boilerplate.

mix phx.new temp_dashboard --no-ecto --no-mailer --no-gettext --no-dashboard --live

First thing, let’s add the required dependencies to the mix.exs


temp_dashboard/mix.exs


  defp deps do
    [
      ...,
      {:emqtt, github: "emqx/emqtt", tag: "1.4.4", system_env: [{"BUILD_WITHOUT_QUIC", "1"}]},
      {:contex, github: "mindok/contex"}, # We will need this for SVG charts
      ...
    ]
  end

Update the dependencies with the newly added bit

mix deps.get

Let’s add few lines to the main configuration and the development one:


temp_dashboard/config/config.exs


config :temp_dashboard, :emqtt,
  host: '192.168.10.12',  # Again, the MQTT broker address here
  port: 1883

config :temp_dashboard, :sensor_id, "weather_sensor"

# Period for chart
config :temp_dashboard, :timespan, 60

Same on temp_dashboard/config/dev.exs.

Let’s now generate a LiveView controller

mix phx.gen.live Measurements Temperature temperatures  --no-schema --no-context

We can remove some of them for our single-page app

rm lib/temp_dashboard_web/live/temperature_live/form_component.*
rm lib/temp_dashboard_web/live/temperature_live/show.*
rm lib/temp_dashboard_web/live/live_helpers.ex

Finally, we want to add some business logic to our app, fire up the editor on temp_dashboard/lib/temp_dashboard_web/live/temperature_live/index.ex and replace the content with the following snippet.


temp_dashboard/lib/temp_dashboard_web/live/temperature_live/index.ex


defmodule TempDashboardWeb.TemperatureLive.Index do
  use TempDashboardWeb, :live_view

  require Logger

  @impl true
  def mount(_params, _session, socket) do
    reports = []
    emqtt_opts = Application.get_env(:temp_dashboard, :emqtt)
    Logger.info(emqtt_opts)
    {:ok, pid} = :emqtt.start_link(emqtt_opts)
    {:ok, _} = :emqtt.connect(pid)
    # Listen reports
    {:ok, _, _} = :emqtt.subscribe(pid, "reports/#")

    {:ok,
     assign(socket,
       reports: reports,
       pid: pid,
       plot: nil,
       interval: nil
     )}
  end

  @impl true
  def handle_params(_params, _url, socket) do
    {:noreply, socket}
  end

  @impl true
  def handle_event("set-interval", %{"interval" => interval_s}, socket) do
    case Integer.parse(interval_s) do
      {interval, ""} ->
        id = Application.get_env(:temp_dashboard, :sensor_id)
        # Send command to device
        topic = "commands/#{id}/set_interval"

        :ok =
          :emqtt.publish(
            socket.assigns[:pid],
            topic,
            interval_s,
            retain: true
          )

        {:noreply, assign(socket, interval: interval)}

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event(name, data, socket) do
    Logger.info("handle_event: #{inspect([name, data])}")
    {:noreply, socket}
  end

  @impl true
  def handle_info({:publish, packet}, socket) do
    Logger.info("handle_event: #{inspect(packet)}")
    handle_publish(parse_topic(packet), packet, socket)
  end

  defp handle_publish(["reports", id, "temperature"], %{payload: payload}, socket) do
    if id == Application.get_env(:temp_dashboard, :sensor_id) do
      report = Jason.decode!(payload)
      {reports, plot} = update_reports(report, socket)
      {:noreply, assign(socket, reports: reports, plot: plot)}
    else
      {:noreply, socket}
    end
  end

  defp update_reports(%{"timestamp" => ts, "temperature" => val}, socket) do
    new_report = {DateTime.from_unix!(ts, :millisecond), val}
    now = DateTime.utc_now()

    deadline =
      DateTime.add(
        DateTime.utc_now(),
        -2 * Application.get_env(:temp_dashboard, :timespan),
        :second
      )

    reports =
      [new_report | socket.assigns[:reports]]
      |> Enum.filter(fn {dt, _} -> DateTime.compare(dt, deadline) == :gt end)
      |> Enum.sort()

    {reports, plot(reports, deadline, now)}
  end

  defp parse_topic(%{topic: topic}) do
    String.split(topic, "/", trim: true)
  end

  defp plot(reports, deadline, now) do
    x_scale =
      Contex.TimeScale.new()
      |> Contex.TimeScale.domain(deadline, now)
      |> Contex.TimeScale.interval_count(10)

    y_scale =
      Contex.ContinuousLinearScale.new()
      |> Contex.ContinuousLinearScale.domain(0, 30)

    options = [
      smoothed: false,
      custom_x_scale: x_scale,
      custom_y_scale: y_scale,
      custom_x_formatter: &x_formatter/1,
      axis_label_rotation: 45
    ]

    reports
    |> Enum.map(fn {dt, val} -> [dt, val] end)
    |> Contex.Dataset.new()
    |> Contex.Plot.new(Contex.LinePlot, 600, 250, options)
    |> Contex.Plot.to_svg()
  end

  defp x_formatter(datetime), do: Calendar.strftime(datetime, "%H:%M:%S")
end

We also need a template view for our page, let’s replace the index.html.heex


temp_dashboard/lib/temp_dashboard_web/live/temperature_live/index.html.heex


<div>
  <%= if @plot do %>
    <%= @plot %>
  <% end %>
</div>

<div>
  <form phx-submit="set-interval">
    <label for="interval">Interval</label>
    <input type="text" name="interval" value={@interval}/>
    <input type="submit" value="Set interval"/>
  </form>
</div>

Last step, adding a handler to route our requests to index, removing the existing / scope


lib/temp_dasboard_web/router.ex


  scope "/", TempDashboardWeb do
    pipe_through :browser

    live "/", TemperatureLive.Index
  end

Get the dependencies and run the application, we should see it connecting to the broker

mix phx.server

Let’s open the brower to localhost:4000.

Phoenix LiveView on RPI4

A “poncho project” is similar to an umbrella project except that it’s actually multiple separate-but-related Elixir apps that use path dependencies instead of in_umbrella dependencies

References

Categories:  #elixir  #iot