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:
- Architecture guide — the supervision tree
(why the group is
:one_for_alland mounted last), the manager state machine, and the exported D-Bus surface. - Host integration guide — the integration
cookbook: every
Improv.Supervisoroption, the status/PubSub contract, the built-in Wi-Fi backend and how to replace it, and the vintage_net-is-optional story. - Protocol and security guide — the wire formats, the 31-byte legacy-advertising budget math, and the session security model that stands in for the Improv authorization handshake.
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
- BlueZ ≥ 5.x with
bluetoothdrunning on the system D-Bus (the usual bluez Nerves/host setup) and a BLE-capable adapter. - Optional:
vintage_netfor the built-in Wi-Fi backend (Improv.Wifi); without it, inject your own scan/configure functions. - Optional:
phoenix_pubsubfor status broadcasts.
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:
- No-connectivity arm gate: the subsystem arms (exports the GATT app + advertisement) only when the device boots with no network connectivity — and only once per boot. A device that's already online never exposes the provisioning surface. The boot connectivity read races DHCP/link bring-up, so arming re-checks after a 20 s grace period.
- Idle timeout (5 min): reset only on a meaningful state advance (first client connect, a valid submit) — never on arbitrary client activity, so a flooding peer cannot hold the session open.
- Absolute session cap (15 min): disarms regardless of activity.
- Connect timeout (30 s): a submit that never reaches full
:internetconnectivity reverts to AUTHORIZED withunable_to_connect, allowing a retry within the session. - The adapter is made non-pairable for the session (no bond is needed for cleartext characteristics) and restored on teardown.
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