defmodule Improv do @moduledoc """ Improv-over-BLE Wi-Fi provisioning manager — the GenServer that ties the protocol codec, GATT server and advertisement into a working subsystem. Always started on BT targets but **disarmed** by default. It arms (exports the GATT app + advertisement, starts a session timer) only when the device boots with no network connectivity, and tears itself back down on a 5-minute idle timeout or once provisioning completes. ## State machine :disarmed → :advertising → :connected → :provisioning → :provisioned A failed/timed-out connect reverts `:provisioning → :connected` and surfaces the reason via `state.error` (an error byte to the client) — there is no distinct `:error` FSM state. We always advertise current-state AUTHORIZED until a submit moves us to PROVISIONING; security comes from the no-connectivity arm gate + the session timeout, not from the Improv authorization handshake. ## Arm policy Arm **only** on a no-connectivity boot, and **only once per boot** — we do NOT re-arm if connectivity drops later (re-arm requires a reboot). After a session ends (timeout or provisioned) we disarm and stay disarmed. ## Timeout reset rule The 5-minute idle timer resets 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 forever. An absolute session cap (default 15 minutes) bounds the whole session regardless of activity. State changes broadcast `{:improv_status, status}` on the `"bluetooth:improv"` PubSub topic (`status_topic/0`) when a `pubsub:` is configured, for host UIs. Wi-Fi scan/apply goes through the `:wifi` dep (`Improv.Wifi` by default); this module routes RPCs to it and pushes the resulting GATT notifications. """ use GenServer require Logger alias Improv.{Advert, GattServer, Protocol} @topic "bluetooth:improv" @session_timeout_ms 5 * 60 * 1000 # Absolute cap on a session regardless of activity, so repeated valid submits # can't extend the idle window indefinitely (a connected peer resets the idle # timer on every valid submit). Hard disarm at this deadline. @session_cap_ms 15 * 60 * 1000 # Brief hold after PROVISIONED so the client can read the result before teardown. @provisioned_hold_ms 10_000 # Bound on how long we wait for the Wi-Fi interface to join after a submit # before declaring the credentials bad and reverting to AUTHORIZED for a retry. @connect_timeout_ms 30_000 # Ignore a request-networks command arriving within this window of the last # accepted one: the first scan's per-network results are still streaming to # the client, so a flood of 0x04s would only multiply that traffic. @scan_debounce_ms 5_000 # Grace period before arming on a seemingly-offline boot: the boot connectivity # read races interface bring-up (DHCP/link), so an Ethernet device looks offline # for the first few seconds. Re-check after this delay and arm only if STILL # offline — otherwise every Ethernet boot would spuriously advertise + suspend # the proxy scan (HW-found on rpi3). @boot_grace_ms 20_000 @connection_topic ["interface", :_, "connection"] @type fsm :: :disarmed | :advertising | :connected | :provisioning | :provisioned # ── public API ────────────────────────────────────────────────────────── def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) end @doc "PubSub topic carrying `{:improv_status, status}` messages." @spec status_topic() :: String.t() def status_topic, do: @topic @doc """ Current provisioning status: `%{state: fsm, error: atom | nil}`. Returns the disarmed shape when the manager isn't running (off-target / subtree down). """ @spec status(GenServer.server()) :: %{state: fsm(), error: atom() | nil} def status(server \\ __MODULE__) do GenServer.call(server, :status) catch :exit, _ -> %{state: :disarmed, error: nil} end # ── pure helpers (host-tested) ───────────────────────────────────────────── @doc "Map an FSM state to the current-state atom we advertise/notify. Pure." @spec current_state_atom(fsm()) :: atom() def current_state_atom(:provisioning), do: :provisioning def current_state_atom(:provisioned), do: :provisioned # advertising / connected / disarmed all present as AUTHORIZED. def current_state_atom(_), do: :authorized @doc "Improv SSID length rule: 1..32 bytes. Pure." @spec valid_ssid?(binary()) :: boolean() def valid_ssid?(ssid) when is_binary(ssid), do: byte_size(ssid) in 1..32 def valid_ssid?(_), do: false @doc """ Password pre-validation: empty (open network) or the WPA-PSK passphrase rule, 8..63 bytes. Rejecting garbage here keeps it from ever reaching `VintageNet.configure` (which would raise inside the wifi seam). Pure. """ @spec valid_password?(binary()) :: boolean() def valid_password?(pwd) when is_binary(pwd) do byte_size(pwd) == 0 or byte_size(pwd) in 8..63 end def valid_password?(_), do: false @doc """ Map a decoded command (or decode error) to the manager action. Pure — the GenServer executes the returned action. Returns: * `{:submit, ssid, pwd}` for a valid submit (SSID 1..32 bytes, password empty or 8..63 bytes), * `:scan` for request-networks, * `{:reject, error_atom}` for an invalid submit / decode failure. """ @spec command_action(Protocol.command() | Protocol.decode_error()) :: {:submit, binary(), binary()} | :scan | :identify | :device_info | {:reject, atom()} def command_action({:submit_wifi, ssid, pwd}) do if valid_ssid?(ssid) and valid_password?(pwd) do {:submit, ssid, pwd} else {:reject, :invalid_rpc} end end def command_action({:request_wifi_networks}), do: :scan def command_action({:identify}), do: :identify def command_action({:device_info}), do: :device_info def command_action({:error, :unknown_command}), do: {:reject, :unknown_command} def command_action({:error, _}), do: {:reject, :invalid_rpc} # ── GenServer ───────────────────────────────────────────────────────────── @impl GenServer def init(opts) do state = %{ gatt: Keyword.get(opts, :gatt, GattServer), advert: Keyword.get(opts, :advert, Advert), wifi: Keyword.get(opts, :wifi, Improv.Wifi), # Wi-Fi interface being provisioned; threaded into every :wifi call and # matched against VintageNet connectivity events. ifname: Keyword.get(opts, :ifname, "wlan0"), # Connectivity probe for the arm gate (0-arity fun returning # :disconnected when offline); nil (the default) reads as online, so # Improv NEVER arms unless the host explicitly opts into offline-driven # arming by wiring a real probe. Fail-closed: an unconfigured host must # not expose the provisioning surface. network_type: Keyword.get(opts, :network_type), # Phoenix.PubSub for {:improv_status, _} broadcasts; nil = no-op. pubsub: Keyword.get(opts, :pubsub), # Identify (0x02) callback — something physically observable (blink an # LED, …), run fire-and-forget off the loop. nil (default) = the command # is unsupported and its capability bit stays off. identify_fun: Keyword.get(opts, :identify_fun), # Device Info (0x03) static strings, normalized to wire order at init. # nil (default) = unsupported, capability bit off. device_info: device_info_strings(Keyword.get(opts, :device_info)), scanner: Keyword.get(opts, :scanner, Bluez.Client), task_sup: Keyword.get(opts, :task_supervisor, Improv.TaskSupervisor), timeout_ms: Keyword.get(opts, :timeout_ms, @session_timeout_ms), session_cap_ms: Keyword.get(opts, :session_cap_ms, @session_cap_ms), connect_timeout_ms: Keyword.get(opts, :connect_timeout_ms, @connect_timeout_ms), boot_grace_ms: Keyword.get(opts, :boot_grace_ms, @boot_grace_ms), provisioned_hold_ms: Keyword.get(opts, :provisioned_hold_ms, @provisioned_hold_ms), scan_debounce_ms: Keyword.get(opts, :scan_debounce_ms, @scan_debounce_ms), subscribe?: Keyword.get(opts, :subscribe?, true), fsm: :disarmed, error: nil, # One-shot arm gate: arm at most once per boot, only when offline. arm_allowed?: true, timer: nil, provision_timer: nil, cap_timer: nil, # Monitor ref of the running identify task — identifies coalesce while # one is in flight (also serializes the LED park/restore side effects). identify_ref: nil, # Monotonic ms of the last accepted scan, for the 0x04 debounce. last_scan_at: nil } {:ok, state, {:continue, :evaluate_boot}} end @impl GenServer def handle_continue(:evaluate_boot, state) do maybe_subscribe_connectivity(state) if online?(state) do Logger.debug("Improv: connectivity present at boot — staying disarmed") {:noreply, state} else # Likely just DHCP/link not finished — re-check after a grace period rather # than arming immediately (avoids spurious arming on Ethernet boots). Logger.debug("Improv: offline at boot — re-checking after #{state.boot_grace_ms}ms") Process.send_after(self(), :evaluate_arm, state.boot_grace_ms) {:noreply, state} end end @impl GenServer def handle_call(:status, _from, state) do {:reply, %{state: state.fsm, error: state.error}, state} end @impl GenServer # Boot-grace re-check: arm only if STILL offline (else it was just DHCP lag). def handle_info(:evaluate_arm, state) do if online?(state) do Logger.info("Improv: connectivity came up during boot grace — staying disarmed") {:noreply, state} else Logger.info("Improv: still no connectivity after boot grace — arming provisioning") {:noreply, arm(state)} end end # Defense-in-depth: only dispatch RPCs in the states where a session is # live. Unreachable via BLE today (the GATT app is only registered while # armed), but a stray/late message must not drive a disarmed manager. def handle_info({:improv_rpc_command, bytes}, %{fsm: fsm} = state) when fsm in [:advertising, :connected, :provisioning] do {:noreply, dispatch_command(bytes, state)} end def handle_info({:improv_rpc_command, _bytes}, state) do Logger.debug("Improv: ignoring RPC command while #{state.fsm}") {:noreply, state} end # First genuine client connect advances :advertising → :connected and resets # the timer. Later activity does NOT reset it (timeout reset rule). def handle_info({:improv_client_activity, _key}, %{fsm: :advertising} = state) do {:noreply, transition(state, :connected)} end def handle_info({:improv_client_activity, _key}, state), do: {:noreply, state} # Provisioned only when the provisioning interface reaches full :internet # connectivity. NOT :lan (a wrong password flaps associate→handshake-fail→drop, # blipping :lan transiently — that would falsely report success), and NOT # other ifaces (their connectivity says nothing about the submitted Wi-Fi). # If it never reaches :internet, the connect timer fires → unable_to_connect. def handle_info( {VintageNet, ["interface", ifname, "connection"], _old, :internet, _meta}, %{fsm: :provisioning, ifname: ifname} = state ) do {:noreply, mark_provisioned(state)} end def handle_info({VintageNet, ["interface", _iface, "connection"], _old, _new, _meta}, state) do {:noreply, state} end # Join in flight — the connect timer bounds it; don't cut it on idle timeout. def handle_info(:session_timeout, %{fsm: :provisioning} = state), do: {:noreply, state} # Already provisioned — the teardown timer is scheduled; don't double-disarm. def handle_info(:session_timeout, %{fsm: :provisioned} = state), do: {:noreply, state} def handle_info(:session_timeout, state) do Logger.info("Improv: session idle timeout — disarming") {:noreply, disarm(state)} end # Absolute session cap — disarm regardless of activity (defeats indefinite # idle-timer extension via repeated valid submits). def handle_info(:session_cap, %{fsm: :disarmed} = state), do: {:noreply, state} def handle_info(:session_cap, state) do Logger.info("Improv: absolute session cap reached — disarming") {:noreply, disarm(state)} end def handle_info(:provisioned_teardown, state) do {:noreply, disarm(state)} end # wlan0 didn't join in time — bad credentials / out of range. Report the error # and revert to AUTHORIZED so the provisioner can retry within the session. # NO idle-timer reset: a failure is not a meaningful state advance (timeout # reset rule), so the deadline armed at the submit stands. def handle_info(:provisioning_failed, %{fsm: :provisioning} = state) do state = push_error(state, :unable_to_connect) {:noreply, state |> Map.put(:provision_timer, nil) |> transition(:connected, reset_timer?: false)} end def handle_info(:provisioning_failed, state), do: {:noreply, state} # The running identify task ended (normally or not) — allow the next 0x02. def handle_info({:DOWN, ref, :process, _pid, _reason}, %{identify_ref: ref} = state) do {:noreply, %{state | identify_ref: nil}} end def handle_info(_other, state), do: {:noreply, state} # ── command dispatch ─────────────────────────────────────────────────────── defp dispatch_command(bytes, state) do case command_action(Protocol.decode_command(bytes)) do {:submit, ssid, pwd} -> submit_wifi(ssid, pwd, state) :scan -> request_networks(state) :identify -> run_identify(state) :device_info -> send_device_info(state) {:reject, error} -> push_error(state, error) end end # Identify: fire-and-forget via the Task.Supervisor, NO RPC result (per # spec). Unconfigured hosts advertise the capability bit off, so a compliant # client never sends it — reject like an unknown command if one does anyway. defp run_identify(%{identify_fun: nil} = state), do: push_error(state, :unknown_command) # Coalesce: ignore 0x02 while an identify task is already running — no error # push (the spec gives identify no result), and running one at a time also # serializes the identify_fun's physical side effects (LED park/restore). defp run_identify(%{identify_ref: ref} = state) when is_reference(ref), do: state defp run_identify(%{identify_fun: fun} = state) do {:ok, pid} = run_task(state, fun) %{state | identify_ref: Process.monitor(pid)} end defp send_device_info(%{device_info: nil} = state), do: push_error(state, :unknown_command) defp send_device_info(state) do notify_result( state, Protocol.encode_rpc_result(Protocol.device_info_command(), state.device_info) ) state end # Wire order per the Improv spec: firmware name, firmware version, hardware # variant, device name. defp device_info_strings(nil), do: nil defp device_info_strings(info) do [ Keyword.fetch!(info, :firmware_name), Keyword.fetch!(info, :firmware_version), Keyword.fetch!(info, :hardware), Keyword.fetch!(info, :device_name) ] end defp submit_wifi(ssid, pwd, state) do state = %{state | error: nil} |> transition(:provisioning) |> arm_provision_timer() # Improv.Wifi applies the credentials; PROVISIONED is detected via the # interface connectivity event above. configure/3 returns quickly (best-effort). safe_apply(state.wifi, :configure, [ssid, pwd, wifi_opts(state)]) state end defp arm_provision_timer(state) do state = cancel_provision_timer(state) %{ state | provision_timer: Process.send_after(self(), :provisioning_failed, state.connect_timeout_ms) } end defp cancel_provision_timer(%{provision_timer: nil} = state), do: state defp cancel_provision_timer(%{provision_timer: ref} = state) do Process.cancel_timer(ref) %{state | provision_timer: nil} end # Debounce: ignore a scan arriving inside the window of the last accepted # one — the client's outstanding scan is still streaming its results, so # push no error either. Monotonic clock (never steps, unlike wall time). defp request_networks(state) do now = System.monotonic_time(:millisecond) if state.last_scan_at != nil and now - state.last_scan_at < state.scan_debounce_ms do state else do_request_networks(%{state | last_scan_at: now}) end end defp do_request_networks(state) do %{gatt: gatt, wifi: wifi} = state wifi_opts = wifi_opts(state) # quick_scan sleeps ~2s — run off the GenServer loop, under the Improv # Task.Supervisor (crash visibility). The Task pushes the per-network results # then the empty terminator so the client's list completes. run_task(state, fn -> networks = case safe_apply(wifi, :scan_networks, [wifi_opts]) do {:ok, ns} when is_list(ns) -> ns _ -> [] end Enum.each(networks, fn n -> GattServer.notify( gatt, :rpc_result, Protocol.encode_wifi_network_entry(n.ssid, n.rssi, n.secured) ) end) GattServer.notify( gatt, :rpc_result, Protocol.encode_rpc_result(Protocol.request_networks_command(), []) ) end) state end # ── arm / disarm / transitions ───────────────────────────────────────────── defp arm(%{fsm: :disarmed, arm_allowed?: true} = state) do # We only arm on a no-connectivity boot — there's no HA client to consume the # proxy scan, so suspend it for the session (also frees the radio for the BLE # peripheral connection). Resumed on disarm. suspend_proxy_scan(state) GattServer.register(state.gatt) Advert.register(state.advert) Advert.set_state(state.advert, :authorized) notify_state(state, :authorized) %{state | fsm: :advertising, arm_allowed?: false, error: nil} |> reset_timer() |> start_cap_timer() |> broadcast() end defp arm(state), do: state defp disarm(state) do Advert.unregister(state.advert) GattServer.unregister(state.gatt) resume_proxy_scan(state) %{state | fsm: :disarmed} |> cancel_timer() |> cancel_provision_timer() |> cancel_cap_timer() |> broadcast() end # Advance the FSM, push the current-state notification + advert update, reset # the idle timer (only *meaningful* advances reset it — the provisioning # failure revert passes `reset_timer?: false`), and broadcast. defp transition(state, new_fsm, opts \\ []) do cs = current_state_atom(new_fsm) notify_state(state, cs) Advert.set_state(state.advert, cs) state = %{state | fsm: new_fsm} state = if Keyword.get(opts, :reset_timer?, true), do: reset_timer(state), else: state broadcast(state) end defp mark_provisioned(state) do state = state |> cancel_provision_timer() |> transition(:provisioned) push_redirect_result(state) # Hold briefly so the client reads the result, then tear down. Process.send_after(self(), :provisioned_teardown, state.provisioned_hold_ms) state end # On success the Improv RPC-result carries the device's web-UI URL on the new # network, so the provisioner can redirect there. Skipped if no IPv4 yet. defp push_redirect_result(state) do case safe_apply(state.wifi, :redirect_url, [wifi_opts(state)]) do url when is_binary(url) -> notify_result(state, Protocol.encode_rpc_result(Protocol.submit_wifi_command(), [url])) _ -> :ok end end defp push_error(state, error) do notify(state, :error_state, Protocol.encode_error(error)) %{state | error: error} |> broadcast() end # ── notifications / effects ──────────────────────────────────────────────── defp notify_state(state, cs_atom), do: notify(state, :current_state, Protocol.encode_state(cs_atom)) defp notify_result(state, bytes), do: notify(state, :rpc_result, bytes) defp notify(state, key, bytes), do: GattServer.notify(state.gatt, key, bytes) # Opts threaded into every :wifi call (each takes a trailing keyword list). defp wifi_opts(state), do: [ifname: state.ifname] # Call the Wi-Fi module through a variable, rescued so a raising impl can't # crash the manager (or the scan Task). Used from both the loop and the Task. # printable_limit bounds the log: the rescued call can carry the submitted # PSK in exception args (e.g. a raising configure), and credentials must not # land in the (RingLogger-persisted) log. defp safe_apply(mod, fun, args) do apply(mod, fun, args) rescue e -> Logger.warning("Improv: wifi #{fun} failed: #{inspect(e, limit: 5, printable_limit: 200)}") {:error, :wifi_unavailable} end defp broadcast(%{pubsub: nil} = state), do: state defp broadcast(state) do Phoenix.PubSub.broadcast( state.pubsub, @topic, {:improv_status, %{state: state.fsm, error: state.error}} ) state end # ── timer ────────────────────────────────────────────────────────────────── defp reset_timer(state) do state = cancel_timer(state) %{state | timer: Process.send_after(self(), :session_timeout, state.timeout_ms)} end defp cancel_timer(%{timer: nil} = state), do: state defp cancel_timer(%{timer: ref} = state) do Process.cancel_timer(ref) %{state | timer: nil} end # Absolute cap, armed once at arm and never reset, so it bounds the whole # session no matter how often the idle timer is refreshed. defp start_cap_timer(state) do state = cancel_cap_timer(state) %{state | cap_timer: Process.send_after(self(), :session_cap, state.session_cap_ms)} end defp cancel_cap_timer(%{cap_timer: nil} = state), do: state defp cancel_cap_timer(%{cap_timer: ref} = state) do Process.cancel_timer(ref) %{state | cap_timer: nil} end # ── tasks ────────────────────────────────────────────────────────────────── defp run_task(%{task_sup: sup}, fun), do: Improv.Tasks.run(sup, fun, "Improv") # ── connectivity ───────────────────────────────────────────────────────── # No probe configured → treated as online → never arms (see init/1). defp online?(%{network_type: nil}), do: true # A raising probe reads as online, i.e. the device STAYS DISARMED # (fail-closed: an error must not open the provisioning surface), and the # crash can't take the :one_for_all group down with it. defp online?(state) do state.network_type.() != :disconnected rescue e -> Logger.warning( "Improv: connectivity probe raised (staying disarmed): " <> inspect(e, limit: 5, printable_limit: 200) ) true end # Best-effort: only cast when the scanner is actually running (no-op off-target # / in host tests). suspend_scan/resume_scan are themselves casts. defp suspend_proxy_scan(state), do: scanner_cast(state.scanner, :suspend_scan) defp resume_proxy_scan(state), do: scanner_cast(state.scanner, :resume_scan) # `GenServer.cast` to an unregistered name already returns `:ok`, so no # whereis-guard is needed (it was a TOCTOU no-op anyway). defp scanner_cast(scanner, msg), do: GenServer.cast(scanner, msg) defp maybe_subscribe_connectivity(%{subscribe?: false}), do: :ok defp maybe_subscribe_connectivity(_state) do if Code.ensure_loaded?(VintageNet) do _ = Application.ensure_all_started(:vintage_net) apply(VintageNet, :subscribe, [@connection_topic]) end :ok end end