NervesHubLinkAVM
A NervesHub client for AtomVM devices.
NervesHubLinkAVM connects AtomVM-powered microcontrollers (ESP32, etc.) to a NervesHub server for over-the-air (OTA) firmware updates via WebSocket.
Features
- WebSocket connection to NervesHub with Phoenix channel protocol (v2.0.0)
- Mutual TLS (mTLS) or shared secret (HMAC) authentication
- OTA firmware updates with streaming download and SHA256 verification
- Pluggable firmware writer for different hardware platforms
- Update lifecycle status reporting (received, downloading, updating, complete, failed)
- Update decision control (apply, ignore, reschedule)
- Firmware validation tracking across reboots
- Pluggable extension system (health reporting, etc.)
- Device identification support (e.g., blink LED on server request)
- Automatic reconnection with exponential backoff
- Heartbeat keep-alive
- Nameable GenServer (supports multiple instances)
- Zero external dependencies -- uses AtomVM's built-in modules
Requirements
-
AtomVM with OTP 28 support (with TLS/Mbed TLS for
wss://) - Elixir ~> 1.15 (for compilation)
Installation
Add nerves_hub_link_avm to your mix.exs dependencies:
def deps do
[
{:nerves_hub_link_avm, "~> 0.1.0"}
]
endUsage
Starting the client
NervesHubLinkAVM.start_link(
host: "your-nerveshub-server.com",
port: 443,
ssl: true,
device_cert: "/path/to/device-cert.pem",
device_key: "/path/to/device-key.pem",
firmware_meta: %{
"uuid" => "firmware-uuid",
"product" => "my-product",
"architecture" => "esp32",
"version" => "1.0.0",
"platform" => "esp32"
},
client: MyApp.Client,
fwup_writer: MyApp.ESP32Writer
)Or with shared secret authentication:
NervesHubLinkAVM.start_link(
host: "your-nerveshub-server.com",
product_key: "nhp_...",
product_secret: "...",
identifier: "my-device-001",
firmware_meta: %{ ... },
client: MyApp.Client,
fwup_writer: MyApp.ESP32Writer
)
Required firmware metadata keys: uuid, product, architecture, version, platform.
Public API
# Force a reconnection (resets backoff)
NervesHubLinkAVM.reconnect()
# Confirm firmware is valid after an update (prevents rollback)
NervesHubLinkAVM.confirm_update()
# With a named instance
NervesHubLinkAVM.reconnect(MyDevice)
NervesHubLinkAVM.confirm_update(MyDevice)Client behaviour
The Client controls update decisions, progress reporting, and device actions. All callbacks are optional with sensible defaults (auto-apply updates, no-op for everything else).
defmodule MyApp.Client do
@behaviour NervesHubLinkAVM.Client
@impl true
def update_available(_meta), do: :apply # or :ignore or {:reschedule, 60_000}
@impl true
def fwup_progress(percent), do: IO.puts("Progress: #{percent}%")
@impl true
def fwup_error(error), do: IO.puts("Error: #{inspect(error)}")
@impl true
def reboot, do: :ok
@impl true
def identify, do: :ok
@impl true
def handle_connected, do: :ok
@impl true
def handle_disconnected, do: :ok
end
A default implementation (NervesHubLinkAVM.Client.Default) is used if no :client is provided.
FwupWriter behaviour
The FwupWriter handles hardware-specific firmware write operations. Implement this for your target platform.
defmodule MyApp.ESP32Writer do
@behaviour NervesHubLinkAVM.FwupWriter
@impl true
def fwup_begin(size, _meta) do
# Erase inactive partition, allocate buffers
{:ok, %{offset: 0, size: size}}
end
@impl true
def fwup_chunk(data, state) do
# Write chunk to flash
{:ok, %{state | offset: state.offset + byte_size(data)}}
end
@impl true
def fwup_finish(_state) do
# Activate new slot, reboot
:ok
end
@impl true
def fwup_abort(_state) do
# Clean up partial writes
:ok
end
# Optional: confirm firmware on first boot
@impl true
def fwup_confirm, do: :ok
endExtensions
Extensions are opt-in and pluggable. Currently supported: health reporting.
NervesHubLinkAVM.start_link(
...
extensions: [health: MyApp.HealthProvider]
)
Implement the HealthProvider behaviour:
defmodule MyApp.HealthProvider do
@behaviour NervesHubLinkAVM.HealthProvider
@impl true
def health_check do
%{"cpu_temp" => 42.5, "mem_used_percent" => 65}
end
end
Custom extensions can implement the NervesHubLinkAVM.Extension behaviour directly.
Update lifecycle
When the server pushes a firmware update:
Client.update_available/1is called -- returns:apply,:ignore, or{:reschedule, ms}-
If
:apply, callsFwupWriter.fwup_begin/2 -
Reports
"downloading"and streams chunks throughFwupWriter.fwup_chunk/2 - Verifies SHA256 hash of the downloaded firmware
-
Reports
"updating"and callsFwupWriter.fwup_finish/1 -
Reports
"fwup_complete"on success
If SHA256 verification fails or any step errors, FwupWriter.fwup_abort/1 is called and "update_failed" is reported.
Channel messages handled
| Server Event | Client Action |
|---|---|
update | Runs update pipeline via UpdateManager |
reboot |
Calls Client.reboot/0 |
identify |
Calls Client.identify/0 |
extensions:get | Joins extensions channel if configured |
health:check |
Calls HealthProvider.health_check/0 |
Architecture
NervesHubLinkAVM (GenServer) -- connection lifecycle, message dispatch
|
|-- Client (behaviour) -- update decisions, progress, lifecycle hooks
| +-- Client.Default -- auto-apply, log everything
|
|-- FwupWriter (behaviour) -- hardware-specific firmware writes
|
|-- Configurator -- builds config from opts (auth, URL, SSL)
|-- UpdateManager -- orchestrates Client + FwupWriter + Downloader
|-- Downloader -- HTTP streaming + SHA256 verification
|
|-- Extension (behaviour) -- pluggable extension interface
| +-- Extension.Health -- wraps HealthProvider for health reporting
|-- Extensions -- routes extension events by key prefix
|
|-- SharedSecret -- HMAC signing for shared secret auth
|-- Channel -- Phoenix channel protocol codec
|-- HTTPClient -- HTTP layer (AtomVM ahttp_client)
|-- JSON -- JSON codec (AtomVM compatible)
|
+-- websocket.erl -- WebSocket client (TCP + TLS) RFC 6455Author
Eliel A. Gordon (gordoneliel@gmail.com)
License
MIT