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})
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}
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.
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.
NervesHub can ask a device to do three things from its page. Two arrive as messages on the device topic and one does not:
identify is passed straight through. Only the application knows what
identifying looks like on its hardware, so nothing is done for it.reboot announces itself with rebooting and then restarts the
device. reboot => manual reports reboot_requested and leaves the
decision alone.reconnect is not a device message at all — NervesHub drops the
socket and the transport reconnects, so there is nothing to
implement.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.
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 => 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 says where the running firmware's description comes from:
boot — the packbeam AtomVM booted, found through the boot path
esp32init records in NVS. This is the default.{partition, Label} — a named partition instead, for a device whose
loader is not esp32init.{metadata, Map} — supply it directly, as built by
nh_metadata:describe/2.none — join without describing the firmware. NervesHub will not know
what the device is running, so it cannot decide whether to update it.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.
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() = boot | {partition, binary()} | {metadata, map()} | none
| firmware_validated/1 | Confirm that the newly booted firmware works. |
| push/3 | Send an arbitrary event on the device channel. |
| send_log/3 | Equivalent to send_log(Pid, Level, Message, #{}).
|
| send_log/4 | Send a log line to NervesHub, if the logging extension is attached. |
| start/1 | |
| start_link/1 | Connect, linked to the calling process. |
| stop/1 | |
| update_failed/2 | Report that an update did not complete. |
| update_progress/2 | Report how far through an update the device is, as a percentage. |
| update_progress/3 | As update_progress/2, naming the stage: downloading or updating. |
firmware_validated(Pid::pid()) -> ok
Confirm that the newly booted firmware works.
Pairs withesp_ota_mark_app_valid_cancel_rollback(): until something calls
it, the bootloader will roll back to the previous image on the next boot.
push(Pid::pid(), Event::binary(), Payload::map()) -> ok
Send an arbitrary event on the device channel.
send_log(Pid::pid(), Level::binary(), Message::binary()) -> ok | {error, term()}
Equivalent to send_log(Pid, Level, Message, #{}).
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(Config::config() | [{atom(), term()}]) -> {ok, pid()} | {error, term()}
start_link(Config::config() | [{atom(), term()}]) -> {ok, pid()} | {error, term()}
Connect, linked to the calling process.
stop(Pid::pid()) -> ok
update_failed(Pid::pid(), Reason::binary()) -> ok
Report that an update did not complete.
update_progress(Pid::pid(), Percent::0..100) -> ok
Report how far through an update the device is, as a percentage.
update_progress(Pid::pid(), Percent::0..100, Stage::binary()) -> ok
As update_progress/2, naming the stage: downloading or updating.
Generated by EDoc