NervesHubLink for Elixir on AtomVM

Copy Markdown View Source

An Elixir face for nerves_hub_link_atomvm_esp32.

A convenience layer, not an abstraction. Every function here is one line deep and the Erlang agent does the work, so :nerves_hub_link can be called directly for anything this does not cover. Nothing here holds state, so mixing the two is ordinary rather than a workaround.

Installing

defp deps do
  [
    {:nerves_hub_link_atomvm_esp32_ex, "~> 0.1"},
    # A rebar3 project and not on Hex, so mix is told both where it is and how
    # to build it.
    {:atomvm_websocket_client,
     github: "nerves-hub/atomvm_websocket_client", manager: :rebar3}
  ]
end

The agent itself is not in that list and does not need to be. This package depends on it, so mix resolves and builds it for you.

The transport does need to be, because nothing depends on it: it is not on Hex, so no published package can. It is an ESP-IDF component first and an Erlang library second, and the half that matters is compiled into the VM rather than fetched by mix. See building the VM.

Usage

{:ok, agent} =
  NervesHubLink.start_link(
    identifier: "my-device",
    shared_secret: {key, secret},
    console: true,
    extensions: :all
  )

Where it connects is worked out for you, so there is no URL above. See nh_url in the agent.

A device needs AtomVM built from source: the WebSocket transport is an ESP-IDF component, and over-the-air updates need a partition table with two packbeam slots rather than the one a stock build has.

. $IDF_PATH/export.sh
mix nerves_hub.atomvm.vm ~/src/AtomVM ~/src/atomvm_websocket_client

That task copies the two files the VM needs out of the agent's priv/atomvm and runs idf.py with the right flags. --dry-run prints what it would do. The agent's README explains what each change buys.

Write the device as a GenServer

The agent sends {:nerves_hub, event} to whatever process is named as its :handler. Name a GenServer and those arrive in handle_info/2:

defmodule Kiosk.Device do
  use GenServer

  def start_link(config), do: GenServer.start_link(__MODULE__, config, name: __MODULE__)

  @impl true
  def init(config) do
    {:ok, agent} =
      NervesHubLink.start_link(
        handler: self(),
        identifier: config.identifier,
        shared_secret: config.shared_secret,
        console: true,
        extensions: :all
      )

    {:ok, %{agent: agent, config: config}}
  end

  @impl true
  def handle_info({:nerves_hub, {:joined, _reply}}, state), do: {:noreply, state}

  def handle_info({:nerves_hub, :identify}, state) do
    spawn(fn -> blink(state.config.led_pin) end)
    {:noreply, state}
  end

  def handle_info({:nerves_hub, {:update_ready, _slot}}, state) do
    Process.send_after(self(), :restart, 500)
    {:noreply, state}
  end

  # Not a NervesHub message, which is the point.
  def handle_info(:restart, state) do
    :esp.restart()
    {:noreply, state}
  end
end

Please see examples/kiosk for an example implementation.

Be careful with a catch-all clause that logs. A log line sent before the logging extension attaches comes back as {:not_joined, "logging:send"}, and logging that sends another line, which comes back again. It settles once the extension is up, but until then the process chases its own tail. Drop that event rather than report it.

Supervision

Supervise the device, and the agent comes with it:

def start do
  config = Config.get()
  :ok = Kiosk.Network.up(config)

  {:ok, _supervisor} = Supervisor.start_link([{Kiosk.Device, config}], strategy: :one_for_one)

  sleep_forever()
end

There is one child, and the agent is not in the list. Kiosk.Device starts it in init/1 with start_link, so the two are linked: if the agent dies the device dies, the supervisor restarts the device, and init/1 connects again. The tree is Supervisor -> Kiosk.Device -> agent, and only the middle one needs a child spec, which use GenServer writes.

Note what start/0 does after starting the supervisor. AtomVM stops when the process that started everything returns, so it waits instead. There is no Application here and no application callback: an app runs from the start/0 named in its own mix.exs, so the tree is something you build there rather than something you get.

Putting the agent in the tree directly

NervesHubLink has a child_spec/1 too, for a device with no GenServer to hang it from:

children = [
  {NervesHubLink, handler: self(), identifier: "my-device", shared_secret: {key, secret}}
]

:handler is required here even though the agent defaults it to whatever started it. Under a supervisor that is the supervisor, which does not read its mailbox, so the default would send every event nowhere. child_spec/1 raises rather than let that happen.

It only fits when the process building the child list is the one that will read the events, since self() is evaluated there. A sibling in the list cannot be named this way, because it does not exist yet. That is the reason the GenServer above starts the agent itself rather than sitting beside it.

Restarts

The agent starts before it connects, so a refused connection is a crash shortly after a successful start, not an error from start_link/1. A supervisor sees a restart rather than a startup failure.

That matters more than it sounds, because :max_restarts defaults to 3 in 5 seconds. A device whose WiFi is not up yet can spend those three restarts before the radio has an address, and then the supervisor exits and takes the application with it. Bringing the network up before starting the tree, as start/0 above does, is the simple fix; raising :max_restarts is the other.

Logging

alias NervesHubLink.Logger, as: Log

Log.info("started")
Log.warning(%{event: :retrying, attempt: 3})

AtomVM's :logger accepts a list or a map and raises badarg on anything else, before any handler sees the event. Elixir strings are binaries, so the most natural line an Elixir developer can write is the one that takes down the process that wrote it. This converts and passes everything else through.

It is :logger underneath, so :nh_logger picks these up like any other event. Nothing here talks to the agent, which is why it works before the agent exists and keeps working if it dies.

Signing

mix nerves_hub.sign --key ~/keys/fwup-key.priv

Signs <app>.avm in place and verifies the result, so a wrong key is found here rather than by a device refusing the update. --check reports without changing anything, and the key path can come from NERVES_HUB_FW_PRIVATE_KEY instead of the flag.

A device only requires this if it is configured with firmware_keys. The key is the organization's existing fwup private key, whose trailing half is the .pub NervesHub already stores, so signing packbeams adds no key management.

To sign every build, chain it onto the packbeam alias:

aliases: [
  "atomvm.packbeam": ["atomvm.application_bin", "atomvm.packbeam", "nerves_hub.sign"]
]

Then a build needs the key present, so a contributor without one cannot build and CI has to be given a key before anything works. That trade is why it is not wired up for you. What the task will not do is quietly succeed without a key: that is how unsigned firmware reaches a product configured to allow unsigned.

Give it a path, never the key itself. mix.exs is committed, and a key in an environment variable is readable in process listings and tends to end up in CI logs.

License

Apache-2.0 OR LGPL-2.1-or-later.