Improv

Improv-over-BLE Wi-Fi provisioning for Elixir/Nerves devices, built on BlueZ over D-Bus.

A device with no network connectivity advertises the Improv BLE service; a provisioner (the Home Assistant companion app, improv-wifi.com, or any Improv client) connects, optionally scans for networks, submits SSID + password, and gets redirected to the device's web UI on the new network.

Implements the full Improv BLE protocol: submit Wi-Fi (0x01), identify (0x02), device info (0x03), and scan networks (0x04), with the capabilities byte derived from what you configure.

Documentation

Start here once you're ready to go beyond the quickstart below:

The core modules carry the reference detail: Improv (manager + every option), Improv.Protocol (the codec), Improv.GattServer / Improv.Advert (the exporters), Improv.Wifi (the backend).

Requirements

Usage

Mount Improv.Supervisor in Bluez's extra_children: slot, appended last — under Bluez's :rest_for_one strategy a bluetoothd/client restart then rebuilds the Improv group (whose exporters hold now-stale D-Bus registrations), while an Improv fault never disturbs the children before it:

{Bluez,
client: [...],
extra_children: [
{Improv.Supervisor,
[
# Connectivity probe for the arm gate. REQUIRED for provisioning to
# ever activate: nil (the default) reads as online = never arm.
network_type: &MyApp.Network.type/0,
# BLE-visible name becomes "My Device <suffix>" (suffix = last 4 hex
# of the device MAC). Or pass local_name: for the full string.
name_prefix: "My Device",
# Optional — Identify (0x02): do something physically observable.
# Sets capability bit 0. Runs fire-and-forget off the manager loop.
identify_fun: &MyApp.Identify.blink/0,
# Optional — Device Info (0x03) strings. Sets capability bit 1.
device_info: [
firmware_name: "My Firmware",
firmware_version: "1.2.3",
hardware: "Raspberry Pi 3 Model B Plus",
device_name: "My Device 507f"
],
# Optional: pubsub: MyApp.PubSub for {:improv_status, _} broadcasts,
# ifname: "wlan0" (default), timeout/cap overrides, …
]}
]}

Improv.status/1 returns %{state: fsm, error: atom | nil} and works on any target — it answers a disarmed shape when the subsystem isn't running.

Security model

The Improv authorization handshake is not used (the GATT characteristics are cleartext); the session is instead bounded by:

ServiceData and legacy (BT 4.x) controllers

The advertisement carries the spec's 6-byte ServiceData [state, capabilities, 0 ×4] keyed by the 16-bit"4677" UUID. That form totals exactly 31 bytes alongside the mandatory Flags and the 128-bit service UUID — the entire legacy advertising budget, with the local name falling to the scan response. Controllers without LE Extended Advertising (e.g. a Raspberry Pi 3's BCM4345C0) reject anything larger: a 128-bit-keyed ServiceData was hardware-tested and refused by bluetoothd with "Invalid Parameters". The payload is static (state frozen at AUTHORIZED): BlueZ reads the advertisement properties once at registration, and clients read live state/capabilities from the GATT characteristics after connecting.

Installation

def deps do
[
{:improv, "~> 0.1"}
]
end