README.md

# NervesHubLinkAVM

A [NervesHub](https://www.nerves-hub.org/) client for [AtomVM](https://www.atomvm.net/) 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:

```elixir
def deps do
  [
    {:nerves_hub_link_avm, "~> 0.1.0"}
  ]
end
```

## Usage

### Starting the client

```elixir
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:

```elixir
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

```elixir
# 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).

```elixir
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.

```elixir
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
end
```

### Extensions

Extensions are opt-in and pluggable. Currently supported: health reporting.

```elixir
NervesHubLinkAVM.start_link(
  ...
  extensions: [health: MyApp.HealthProvider]
)
```

Implement the `HealthProvider` behaviour:

```elixir
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:

1. `Client.update_available/1` is called -- returns `:apply`, `:ignore`, or `{:reschedule, ms}`
2. If `:apply`, calls `FwupWriter.fwup_begin/2`
3. Reports `"downloading"` and streams chunks through `FwupWriter.fwup_chunk/2`
4. Verifies SHA256 hash of the downloaded firmware
5. Reports `"updating"` and calls `FwupWriter.fwup_finish/1`
6. 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 6455
```

## Author

Eliel A. Gordon (gordoneliel@gmail.com)

## License

MIT