Module nerves_hub_link

Connect an AtomVM device to NervesHub.

Description

Connect an AtomVM device to NervesHub.

  {ok, _Pid} = nerves_hub_link:start(#{
      identifier    => <<"my-device">>,
      shared_secret => {Key, Secret},
      firmware      => boot
  }).

A keyword list works as well as a map, which is what an Elixir caller will write:

  :nerves_hub_link.start(identifier: "my-device", shared_secret: {key, secret})

Where it connects

Nothing above says where, because there is only one URL a device sensibly wants and it can be worked out. host names a different server, url takes one written out in as much detail as you like, and whatever is missing is filled in:

  (nothing)                          wss://devices.nervescloud.com/device-socket/websocket?vsn=2.0.0
  #{host => "nh.example.com"}        wss://nh.example.com/device-socket/websocket?vsn=2.0.0
  #{url => "ws://192.168.1.10:4000"} ws://192.168.1.10:4000/device-socket/websocket?vsn=2.0.0

The path depends on how the device authenticates: a shared secret goes through NervesHub's web endpoint, where the device socket is mounted at /device-socket because /socket is the browser socket, and a client certificate goes to the device endpoint, where it is at /socket. See nh_url.

url and host together is an error rather than a precedence rule.

The calling process receives {nerves_hub, Event}:

  {joined, Response}
  {join_error, Reason}
  {message, Event, Payload}    %% "update" and friends
  {update_started, Pid}
  {update_ready, Slot}         %% written and armed; reboot when ready
  {update_failed, Reason}
  {firmware_committed, Slot}   %% the running update proved itself
  identify                     %% blink something
  reboot_requested
  console_joined
  {disconnected, Reason}
  {transport_error, Reason}

Authentication

Either shared_secret => {Key, Secret} or client_cert => {CertPem, KeyPem}. Which one to use is an organization's choice, and NervesHub accepts both.

A shared-secret signature is time-bound: NervesHub refuses one signed more than 90 seconds ago. An ESP32 boots at the epoch, so the clock has to be set — by SNTP, say — before connecting, or every signature is decades stale. start/1 refuses with {error, {clock_not_set, Now}} rather than letting the socket answer a bare 401.

The remote console

console => true joins NervesHub's console channel, off by default. On Nerves nerves_hub_link answers that channel with a real IEx session; AtomVM has no shell, so this answers it with a debug terminal instead — a fixed set of commands, listed by help. See nh_console.

It reports, and it reboots. It will not evaluate Erlang, and it is not a way in to a running system.

Actions

NervesHub can ask a device to do three things from its page. Two arrive as messages on the device topic and one does not:

Sending logs

nerves_hub_link:send_log/3 sends one line. To send everything an application logs, add nh_logger to logger's handlers and start the agent with register => nerves_hub_link so the handler can find it:

  logger_manager:start_link(#{
      log_level => info,
      logger => [
          {handler, default, logger_std_h, #{}},
          nh_logger:handler(#{level => info})
      ]
  })

Elixir has no Logger on AtomVM, so Elixir code calls :logger and is caught by the same handler.

Log a charlist or a map, never a binary: AtomVM's logger raises badarg on a binary message, which takes down the process that logged it. See nh_logger.

That handler catches everything going through logger and nothing else, and a great deal of AtomVM code -- including most of the examples -- reports what it is doing with io:format/2. capture_io => true sends that too:

  nerves_hub_link:start(Config#{capture_io => true})

It makes a capture process the calling process's group leader, so what that process and everything it starts afterwards print is forwarded as well as shown on the console. The text arrives unstructured, at one level, timed on arrival -- there is no level or module in an io:format call to carry over. ESP-IDF's own I (1234) wifi: ... lines are written from C and are not caught by anything here.

One thing to change when turning it on: logger runs its handlers in the process that logged, so logger_std_h -- which reports by printing -- is captured as well, and every logged line arrives twice. nh_console_h prints the same line straight to the console and is not captured:

  logger_manager:start_link(#{
      log_level => info,
      logger => [
          {handler, default, nh_console_h, #{level => info}},
          nh_logger:handler(#{level => info})
      ]
  })

Read nh_io_capture before turning it on. The failure mode of a group leader is a printing process that waits forever, so the module is written to answer every request whatever it is, and it is off by default.

Verifying firmware

firmware_keys are the organization's Ed25519 public keys, as base64 or raw bytes. An fwup .pub file is exactly what goes here, because a packbeam is signed with the same key.

Configuring them is what asks for signatures. A device with keys refuses any update they do not cover, including one carrying no signature at all, and refuses it *before* the boot path moves — so a rejected archive sits in a slot nothing boots from and the device keeps running what it had. A device with no keys installs what NervesHub sent it.

Updates

updates => auto (the default) downloads and installs an update NervesHub offers, into the slot the device is not running, and reports {update_ready, Slot} when it is armed. Rebooting is left to the application. updates => manual reports the message and does nothing else.

Firmware description

firmware says where the running firmware's description comes from:

boot can be the default because it is not a guess. An ESP-IDF device cannot ask which of ota_0/ota_1 it is running, so naming a partition there was a claim that quietly became false after the first update. AtomVM records the boot path, so this stays correct across updates and there is nothing left for a caller to get wrong.

Data Types

config()

config() = #{url => binary() | string(), host => binary() | string(), identifier := binary(), firmware => firmware_source(), shared_secret => {binary(), binary()}, client_cert => {binary(), binary()}, verify => crt_bundle | {cacert_pem, binary()} | none, console => boolean(), extensions => all | [health | geo | logging], reboot => auto | manual, updates => auto | manual, firmware_keys => [binary() | string()], request_firmware_keys => boolean(), capture_io => boolean() | map(), register => atom(), handler => pid(), heartbeat_ms => pos_integer(), transport => module()}

firmware_source()

firmware_source() = boot | {partition, binary()} | {metadata, map()} | none

Function Index

firmware_validated/1Confirm that the newly booted firmware works.
push/3Send an arbitrary event on the device channel.
send_log/3Equivalent to send_log(Pid, Level, Message, #{}).
send_log/4Send a log line to NervesHub, if the logging extension is attached.
start/1
start_link/1Connect, linked to the calling process.
stop/1
update_failed/2Report that an update did not complete.
update_progress/2Report how far through an update the device is, as a percentage.
update_progress/3As update_progress/2, naming the stage: downloading or updating.

Function Details

firmware_validated/1

firmware_validated(Pid::pid()) -> ok

Confirm that the newly booted firmware works.

Pairs with esp_ota_mark_app_valid_cancel_rollback(): until something calls it, the bootloader will roll back to the previous image on the next boot.

push/3

push(Pid::pid(), Event::binary(), Payload::map()) -> ok

Send an arbitrary event on the device channel.

send_log/3

send_log(Pid::pid(), Level::binary(), Message::binary()) -> ok | {error, term()}

Equivalent to send_log(Pid, Level, Message, #{}).

send_log/4

send_log(Pid::pid(), Level::binary(), Message::binary(), Meta::map()) -> ok | {error, term()}

Send a log line to NervesHub, if the logging extension is attached.

{error, no_clock} when the device's clock has never been set: NervesHub requires a timestamp and drops a line without one, and a line dated 1970 is worse than no line. See nh_ext_logs.

start/1

start(Config::config() | [{atom(), term()}]) -> {ok, pid()} | {error, term()}

start_link/1

start_link(Config::config() | [{atom(), term()}]) -> {ok, pid()} | {error, term()}

Connect, linked to the calling process.

stop/1

stop(Pid::pid()) -> ok

update_failed/2

update_failed(Pid::pid(), Reason::binary()) -> ok

Report that an update did not complete.

update_progress/2

update_progress(Pid::pid(), Percent::0..100) -> ok

Report how far through an update the device is, as a percentage.

update_progress/3

update_progress(Pid::pid(), Percent::0..100, Stage::binary()) -> ok

As update_progress/2, naming the stage: downloading or updating.


Generated by EDoc