Everything the library needs from your application flows through
Improv.Supervisor's opts. This guide is the integration cookbook: the
full option surface, the status/PubSub contract, and what the built-in
Wi-Fi backend does versus what you can inject.
The running example is a Nerves ESPHome-style proxy (the application the library was extracted from), but nothing here is specific to it.
Wiring it up
Mount the supervisor in Bluez's extra_children: slot, appended last
(see the Architecture guide for why):
{Bluez,
client: [...],
gatt: [...],
extra_children: [
{Improv.Supervisor,
[
# ── arm gate (REQUIRED for provisioning to ever activate) ──────
# 0-arity fun; :disconnected = offline. nil (the default) reads as
# online, so an unconfigured host NEVER arms — fail-closed.
network_type: &MyApp.System.network_type/0,
# ── branding ────────────────────────────────────────────────────
# BLE-visible name becomes "My Device <suffix>", suffix = last 4
# hex of the device MAC (hostname tail off-target). Or pass
# local_name: "exact string" to skip suffixing.
name_prefix: "My Device",
# ── optional commands (capability bits derive from these) ──────
# Identify (0x02): something physically observable; run
# fire-and-forget under Improv.TaskSupervisor, may block/sleep.
identify_fun: &MyApp.Identify.blink_led/0,
# Device Info (0x03): static strings, resolved when you build the
# child spec.
device_info: [
firmware_name: "My Firmware",
firmware_version: "1.2.3",
hardware: "Raspberry Pi 3 Model B Plus",
device_name: "My Device 507f"
],
# ── plumbing (all optional) ─────────────────────────────────────
pubsub: MyApp.PubSub, # {:improv_status, _} broadcasts; nil = no-op
ifname: "wlan0", # interface being provisioned (default)
scanner: Bluez.Client # gets suspend_scan/resume_scan casts (default)
]}
]}Improv.Advert.device_suffix/0 is public so the host can build other
device-facing strings that match the advertised name — the example app
uses it for device_info:'s device_name.
Timer overrides (timeout_ms, session_cap_ms, connect_timeout_ms,
boot_grace_ms, provisioned_hold_ms) exist mainly for tests; the
defaults implement the security model in the README.
Capabilities are derived, not configured
There is no capabilities: option. The supervisor derives
the byte once — scan-wifi always (built in), identify iff identify_fun:
is set, device-info iff device_info: is set — and threads the same
value to the GATT capabilities characteristic and the advertisement's
ServiceData, so the two can never disagree. Configure both callbacks and
clients see 0x07.
A command whose capability bit is off is rejected with the Improv
unknown_command error if a non-compliant client sends it anyway.
Status and PubSub
Improv.status/1 returns %{state: fsm, error: atom | nil} and is safe
to call on any target: it answers %{state: :disarmed, error: nil} when
the subsystem isn't running (non-BT target, subtree down) instead of
raising. Note what the catch :exit there swallows — a wedged manager
also renders as disarmed rather than crashing the caller.
With a pubsub: configured, every state change and pushed error
broadcasts
{:improv_status, %{state: fsm, error: atom | nil}}on the "bluetooth:improv" topic (Improv.status_topic/0). A LiveView
status card subscribes on mount and re-renders on the message; the FSM
states map to display as: :disarmed (idle), :advertising/:connected
(window open — all present as AUTHORIZED to BLE clients),
:provisioning, :provisioned.
The Wi-Fi backend
Improv.Wifi is the default wifi: implementation, built on VintageNet
(an optional dependency — see below):
scan_networks/1reads the liveaccess_pointsproperty thatwpa_supplicantkeeps refreshed, notVintageNetWiFi.quick_scan/1(whose fresh-scan + 2 s sleep lands in the empty mid-scan window on hardware), and kicks an asyncVintageNet.scan/1for the next call.configure/3applies a WPA-PSK (or open, for an empty password) DHCP config to the interface withpersist: truesemantics — the credentials survive the reboot.redirect_url/1buildshttp://<ip>/from the interface's first IPv4 address for the post-provision redirect.
Every function takes ifname: in its trailing opts (the manager threads
its own ifname: automatically) and injectable vintage_get: /
scan_trigger: / configure_fn: funs.
To replace the backend entirely, pass wifi: MyBackend — any module
with the same three functions (each accepting the trailing opts list).
The manager calls it through a rescue, so a raising backend degrades to
wifi_unavailable instead of crashing the session.
vintage_net is optional — and runtime: false
The dependency is declared optional: true, runtime: false:
- optional — hosts that inject a custom backend never pull it.
- runtime: false — this only affects the library's own dev/test
runs: started on a host machine, vintage_net writes
/etc/resolv.conf. It does not propagate to consumers: a Nerves app depends on vintage_net directly, and that declaration governs its startup.
All VintageNet access in the library is Code.ensure_loaded?-guarded
and rescued, so the modules load and the decision logic is testable with
the dependency absent, present-but-stopped, or running.