Run this notebook

Use Livebook to open this notebook and explore new ideas.

It is easy to get started, on your machine or the cloud.

Click below to open and run it in your Livebook at .

(or change your Livebook location)

<!-- livebook:{"app_settings":{"auto_shutdown_ms":60000,"slug":"rustler-playground-test"}} --> # Rustler BLE playground ```elixir Mix.install([ {:kino, "~> 0.15.3"}, # , ref: "536ce4f231c14bdf6e7746b726e9fa0d82df393f" # {:kino, github: "livebook-dev/kino"}, {:rustler_btleplug, "~> 0.0.14-alpha"} ]) ``` ## Section ```elixir defmodule RustlerBtleplug.Mermaid do @doc """ Converts adapter state into a valid Mermaid.js format with grouped connected/disconnected devices. """ def to_mermaid(%{adapter: adapter, peripherals: peripherals} = adapter_state) do IO.puts(inspect(adapter_state)) adapter_name = normalize_id(adapter.name) connected_node = "node_connected((\"Connected Devices\"))" disconnected_node = "node_disconnected((\"Disconnected Devices\"))" node_near = "node_near((\"Near (Strong RSSI)\"))" node_middle = "node_middle((\"Middle (Moderate RSSI)\"))" node_far = "node_far((\"Far (Weak RSSI)\"))" # Define Mermaid Graph Start mermaid_base = [ # "graph TD", "graph LR", "classDef connected fill:#bbf,stroke:#00f,stroke-width:2px;", "classDef disconnected fill:#eee,stroke:#888,stroke-dasharray:5;", "#{adapter_name}((\"Adapter: #{adapter.name}\"))", "#{adapter_name} --> #{connected_node}", "#{adapter_name} --> #{disconnected_node}", "#{disconnected_node} --> #{node_near}", "#{disconnected_node} --> #{node_middle}", "#{disconnected_node} --> #{node_far}", "" ] # Grouped processing for peripherals {connected_peripherals, disconnected_peripherals} = Enum.split_with(peripherals, fn p -> p.is_connected == true end) mermaid_connected = Enum.map(connected_peripherals, fn peripheral -> peripheral_id = normalize_id(peripheral.id) peripheral_label = "Peripheral: #{peripheral.name} (RSSI: #{peripheral.rssi || "N/A"})" entry = "node_connected --> #{peripheral_id}[\"#{peripheral_label}\"]:::connected" services = process_services(peripheral_id, peripheral.services) [entry | services] end) # Cluster disconnected peripherals based on RSSI {near, middle, far} = cluster_by_rssi(disconnected_peripherals) mermaid_near = Enum.map(near, fn peripheral -> peripheral_id = normalize_id(peripheral.id) peripheral_label = "Peripheral: #{peripheral.name} (RSSI: #{peripheral.rssi || "N/A"})" entry = "#{node_near} -.-> #{peripheral_id}[\"#{peripheral_label}\"]:::disconnected" services = process_services(peripheral_id, peripheral.services) [entry | services] end) mermaid_middle = Enum.map(middle, fn peripheral -> peripheral_id = normalize_id(peripheral.id) peripheral_label = "Peripheral: #{peripheral.name} (RSSI: #{peripheral.rssi || "N/A"})" entry = "#{node_middle} -.-> #{peripheral_id}[\"#{peripheral_label}\"]:::disconnected" services = process_services(peripheral_id, peripheral.services) [entry | services] end) mermaid_far = Enum.map(far, fn peripheral -> peripheral_id = normalize_id(peripheral.id) peripheral_label = "Peripheral: #{peripheral.name} (RSSI: #{peripheral.rssi || "N/A"})" entry = "#{node_far} -.-> #{peripheral_id}[\"#{peripheral_label}\"]:::disconnected" services = process_services(peripheral_id, peripheral.services) [entry | services] end) (mermaid_base ++ List.flatten(mermaid_connected) ++ List.flatten(mermaid_near) ++ List.flatten(mermaid_middle) ++ List.flatten(mermaid_far)) |> Enum.join("\n") end defp cluster_by_rssi(peripherals) do Enum.reduce(peripherals, {[], [], []}, fn peripheral, {near, middle, far} -> rssi = peripheral.rssi || -100 case rssi do rssi when rssi >= -60 -> {[peripheral | near], middle, far} rssi when rssi >= -80 -> {near, [peripheral | middle], far} _ -> {near, middle, [peripheral | far]} end end) |> then(fn {near, middle, far} -> {Enum.reverse(near), Enum.reverse(middle), Enum.reverse(far)} end) end # Processes services and characteristics defp process_services(peripheral_id, services) do Enum.flat_map(services, fn service -> service_id = normalize_id(service.uuid) service_entry = "#{peripheral_id} --> #{service_id}{{\"Service: #{service.uuid}\"}}" characteristics = Enum.map(service.characteristics, fn char -> char_id = normalize_id(char.uuid) properties = Enum.join(char.properties, ", ") "#{service_id} --> #{char_id}([\"Characteristic: #{char.uuid} (#{properties})\"])" end) [service_entry | characteristics] end) end # Normalizes node IDs by prefixing them to avoid Mermaid syntax errors defp normalize_id(id) do "node_" <> String.replace(id, "-", "_") end end defmodule RustlerBtleplug.GenserverLiveBook do @name :ble_genserver_livebook @default_timeout 3000 @graph_debounce 1000 use GenServer require Logger defstruct peripheral: nil, central: nil, ble_messages: [], datatable: nil, graph_frame: nil, frame: nil, graph_timer: nil def start_link(frame) do GenServer.start_link(__MODULE__, %{frame: frame}, name: @name) end def init(state) do Process.flag(:trap_exit, true) # IO.puts("#{__MODULE__} init #{inspect(opts)}") {:ok, state, {:continue, :setup}} end def format_datatable(key, value) do case key do # :type -> value # :uuid -> value :payload -> {:ok, value} # :payload -> {:ok, "#{value |> String.slice(0..20)} ..." |> dbg()} _ -> {:ok, value} end end def handle_continue(_, state) do graph_frame = Kino.Frame.new() datatable = Kino.DataTable.new( [], keys: [:type, :uuid, :payload], formatter: &format_datatable/2, name: "Ble messages", num_rows: 30 ) Kino.Frame.render(state.frame, Kino.Layout.grid([graph_frame, datatable])) {:noreply, %{ central: nil, peripheral: nil, ble_messages: [], datatable: datatable, frame: state.frame, graph_frame: graph_frame, graph_timer: nil }} end @spec format_payload(any) :: String # Handle nil values explicitly def format_payload(nil), do: "" def format_payload(payload) when is_binary(payload), do: payload def format_payload(payload) when is_list(payload), do: Enum.join(payload, ", ") def format_payload(%{} = payload) do payload |> Enum.map(fn {key, value} -> # Recursive call for nested structures "#{key}: #{format_payload(value)}" end) |> Enum.join(", ") end # Fallback for other data types def format_payload(payload), do: inspect(payload) def update_state_with_message(state, msg) do formatted_payload = format_payload(msg.payload) formatted_msg = msg |> Map.put(:payload, formatted_payload) |> Map.put(:type, String.replace(msg.type, "btleplug_", "")) # IO.puts(inspect(formatted_msg)) new_state = %{state | ble_messages: Enum.take([formatted_msg | state.ble_messages], 50)} Kino.DataTable.update(state.datatable, new_state.ble_messages, keys: [:type, :uuid, :payload]) new_state end def update_graph(_state) do Process.send_after(self(), :update_graph, 0) end def handle_info(:update_graph, state) do case state.central do nil -> # IO.puts(":update_graph, no central") {:noreply, state} _ -> case state.graph_timer do nil -> # IO.puts(":update_graph, draw graph") # graphviz_str = RustlerBtleplug.Native.get_adapter_state_graph(state.central) # , "graph" adapter_state = RustlerBtleplug.Native.get_adapter_state_map(state.central) mermaid_text = RustlerBtleplug.Mermaid.to_mermaid(adapter_state) graph = Kino.Mermaid.new(mermaid_text) Kino.Frame.render(state.graph_frame, graph) timer_ref = Process.send_after(self(), :graph_timer_expired, @graph_debounce) {:noreply, %{state | graph_timer: timer_ref}} _ -> # IO.puts(":update_graph, timer active, ignore") {:noreply, state} end end end def handle_info(:graph_timer_expired, state) do # IO.puts(":graph_timer_expired") {:noreply, %{state | graph_timer: nil}} end def handle_info({:btleplug_scan_started, msg}, state) do update_graph(state) {:noreply, update_state_with_message(state, %{type: "btleplug_scan_started", uuid: "", payload: msg})} end def handle_info({:btleplug_peripheral_discovered, uuid, props}, state) do # %{"address" => address, "address_type" => address, "local_name" => local_name, "manufacturer_data" => manufacturer_data, "rssi" => rssi, "service_data" => service_data, "services" => services, "tx_power_level" => tx_power_level} update_graph(state) {:noreply, update_state_with_message(state, %{ type: "btleplug_peripheral_discovered", uuid: uuid, payload: %{ local_name: props["local_name"], rssi: props["rssi"] # services: Map.keys(props["services"]).join(",") } })} end def handle_info({:btleplug_peripheral_connected, uuid}, state) do # %{"address" => address, "address_type" => address, "local_name" => local_name, "manufacturer_data" => manufacturer_data, "rssi" => rssi, "service_data" => service_data, "services" => services, "tx_power_level" => tx_power_level} update_graph(state) {:noreply, update_state_with_message(state, %{ type: "btleplug_peripheral_connected", uuid: uuid, payload: "" })} end def handle_info({:btleplug_services_advertisement, {uuid, services}}, state) do {:noreply, update_state_with_message(state, %{ type: "btleplug_services_advertisement", uuid: uuid, payload: services })} end def handle_info({:btleplug_service_data_advertisement, {uuid, service_data}}, state) do # %{"0000fe2c-0000-1000-8000-00805f9b34fb" => [0, 64, 2, 1, 65, 84, 17, 118]} {:noreply, update_state_with_message(state, %{ type: "btleplug_service_data_advertisement", uuid: uuid, payload: service_data })} end def handle_info({:btleplug_peripheral_updated, uuid, props}, state) do # %{"0000fe2c-0000-1000-8000-00805f9b34fb" => [0, 64, 2, 1, 65, 84, 17, 118]} {:noreply, update_state_with_message(state, %{ type: "btleplug_peripheral_updated", uuid: uuid, payload: %{ local_name: props["local_name"], rssi: props["rssi"] # services: Map.keys(props["services"]).join(",") } })} end def handle_info( {:btleplug_manufacturer_data_advertisement, {uuid, _data} = _service_data}, state ) do # {"4a11a274-c1da-c0cb-7005-ca0e81e8278d", %{301 => [4, 0, 2, 2, 176, 49, 6, 1, 206, 216, 225, 241, 217, 16, 2, 0, 51, 0, 0, 0]} {:noreply, update_state_with_message(state, %{ type: "btleplug_manufacturer_data_advertisement", uuid: "", payload: uuid })} end def handle_info({:btleplug_characteristic_value_changed, uuid, value_data}, state) do # {:btleplug_characteristic_value_changed, "61d20a90-71a1-11ea-ab12-0800200c9a66", [240, 126, 167, 189]} {:noreply, update_state_with_message(state, %{ type: "btleplug_characteristic_value_changed", uuid: uuid, payload: value_data })} end def handle_info({:btleplug_peripheral_disconnected, uuid}, state) do update_graph(state) {:noreply, update_state_with_message(state, %{ type: "btleplug_peripheral_disconnected", uuid: uuid, payload: "" })} end def handle_info({:btleplug_scan_stopped, msg}, state) do update_graph(state) {:noreply, update_state_with_message(state, %{type: "btleplug_scan_stopped", uuid: "", payload: msg})} end def create_central() do GenServer.call(@name, {:create_central}) end def start_scan(timeout \\ @default_timeout) do # Logger.debug("client :start_scan") GenServer.cast(@name, {:start_scan, timeout}) end def stop_scan() do # Logger.debug("client :stop_scan") GenServer.cast(@name, {:stop_scan}) end def find_peripheral_by_name(device_name, timeout \\ @default_timeout) do # Logger.debug("client :find_peripheral_by_name #{device_name}") GenServer.call(@name, {:find_peripheral_by_name, device_name, timeout}) end def connect(timeout \\ @default_timeout) do # Logger.debug("client :connect") GenServer.call(@name, {:connect, timeout}) end def disconnect(timeout \\ @default_timeout) do # Logger.debug("client :connect") GenServer.call(@name, {:disconnect, timeout}) end def subscribe(uuid, timeout \\ @default_timeout) do # Logger.debug("client :subscribe characteristic uuid: #{uuid}") GenServer.call(@name, {:subscribe, uuid, timeout}) end def get_ble_messages() do GenServer.call(@name, {:get_ble_messages}) end def handle_cast({:set_central, central_ref}, state) do # Logger.debug("handle_cast :set_central #{inspect(central_ref)}") new_state = state |> Map.put(:central, central_ref) # Logger.debug("handle_cast :set_central new_state: #{inspect(new_state)}") {:noreply, new_state} end def handle_cast({:start_scan, timeout}, state) do # Logger.debug("handle_cast :start_scan #{inspect(state)}") case state.central do nil -> # Logger.debug("No central reference to start scan.") {:noreply, state} central_ref -> # Call NIF to stop the scan using the central reference case RustlerBtleplug.Native.start_scan(central_ref, timeout) do {:error, reason} -> Logger.debug("Failed to start scan: #{reason}") {:noreply, state} _central_ref -> # Logger.debug("Scan Started.") Process.sleep(1000) {:noreply, state} end end end def handle_cast({:stop_scan}, state) do # Logger.debug("handle_cast :stop_scan #{inspect(state)}") case state.central do nil -> # Logger.debug("No central reference to stop scan.") {:noreply, state} central_ref -> # Call NIF to stop the scan using the central reference case RustlerBtleplug.Native.stop_scan(central_ref) do {:error, _reason} -> # Logger.debug("Failed to stop scan: #{reason}") {:noreply, state} _central_ref -> # Logger.debug("Scan Stopped.") {:noreply, state} end end end def handle_call({:create_central}, _from, state) do case RustlerBtleplug.Native.create_central() do {:error, reason} -> {:error, reason} central_ref -> GenServer.cast(@name, {:set_central, central_ref}) # Logger.debug("Central Created and Reference Stored!") {:reply, {:ok, central_ref}, state} end end def handle_call({:find_peripheral_by_name, device_name, timeout}, _from, state) do case state.central do nil -> # Logger.debug("No central reference to find_peripheral_by_name.") {:noreply, state} central_ref -> case RustlerBtleplug.Native.find_peripheral_by_name(central_ref, device_name, timeout) do {:error, _reason} -> # Logger.debug("Failed to find #{device_name}: #{reason}") {:noreply, state} peripheral_ref -> # Logger.debug("Peripheral #{device_name} found #{inspect(peripheral_ref)}") {:reply, {:ok, peripheral_ref}, %{state | peripheral: peripheral_ref}} end end end def handle_call({:connect, timeout}, _from, state) do case state.peripheral do nil -> # Logger.debug("No peripheral reference to connect.") {:noreply, state} peripheral_ref -> case RustlerBtleplug.Native.connect(peripheral_ref, timeout) do {:error, _reason} -> # Logger.debug("Failed to connect to #{inspect(peripheral_ref)}: #{reason}") {:noreply, state} peripheral_ref -> # Logger.debug("Connecting to #{inspect(peripheral_ref)}") {:reply, {:ok, peripheral_ref}, %{state | peripheral: peripheral_ref}} end end end def handle_call({:disconnect, timeout}, _from, state) do case state.peripheral do nil -> # Logger.debug("No peripheral reference to connect.") {:noreply, state} peripheral_ref -> case RustlerBtleplug.Native.disconnect(peripheral_ref, timeout) do {:error, _reason} -> # Logger.debug("Failed to connect to #{inspect(peripheral_ref)}: #{reason}") {:noreply, state} _peripheral_ref -> # Logger.debug("Connecting to #{inspect(peripheral_ref)}") {:reply, :ok, %{state | peripheral: nil}} end end end def handle_call({:subscribe, uuid, timeout}, _from, state) do case state.peripheral do nil -> # Logger.debug("No peripheral reference to subscribe to.") {:noreply, state} peripheral_ref -> case RustlerBtleplug.Native.subscribe(peripheral_ref, uuid, timeout) do {:error, _reason} -> # Logger.debug("Failed to subscribe to #{uuid}: #{reason}") {:noreply, state} peripheral_ref -> # Logger.debug("Subscribing to #{uuid} #{inspect(peripheral_ref)}") {:reply, {:ok, peripheral_ref}, %{state | peripheral: peripheral_ref}} end end end def handle_call({:get_ble_messages}, _from, state) do # Logger.debug("handle_call :get_ble_messages") {:reply, state.ble_messages, state} end end ``` ```elixir default_timeout = 3000 frame = Kino.Frame.new() ble_frame = Kino.Frame.new() btn_scan = Kino.Control.button("Scan") btn_stop_scan = Kino.Control.button("Stop scan") btn_conn = Kino.Control.button("Connect and subscribe") btn_disconn = Kino.Control.button("Disconnect") select_peripherals = Kino.Input.select("Peripherals", en: "English", fr: "Français") Kino.Frame.append( frame, Kino.Layout.grid([btn_scan, btn_stop_scan, btn_conn, btn_disconn, select_peripherals], columns: 4 ) ) Kino.Frame.append( frame, Kino.Layout.grid([ble_frame], columns: 1) ) Kino.start_child!({RustlerBtleplug.GenserverLiveBook, ble_frame}) {:ok, central_ref} = RustlerBtleplug.GenserverLiveBook.create_central() # IO.puts("Is central_ref a reference: #{inspect(is_reference(central_ref))}") Kino.listen(btn_scan, fn _event -> # IO.inspect(event) RustlerBtleplug.GenserverLiveBook.start_scan() end) Kino.listen(btn_stop_scan, fn _event -> # IO.inspect(event) RustlerBtleplug.GenserverLiveBook.stop_scan() end) Kino.listen(btn_conn, fn _event -> # IO.inspect(event) Task.start(fn -> # Process.sleep(1000) RustlerBtleplug.GenserverLiveBook.find_peripheral_by_name("BLE") RustlerBtleplug.GenserverLiveBook.connect() # RustlerBtleplug.GenserverLiveBook.subscribe("61d20a90-71a1-11ea-ab12-0800200c9a66") RustlerBtleplug.GenserverLiveBook.subscribe("7d911010-e171-4550-bc7e-6d3c79695905") end) end) Kino.listen(btn_disconn, fn _event -> # IO.inspect(event) RustlerBtleplug.GenserverLiveBook.disconnect() end) frame ```
See source

Have you already installed Livebook?

If you already installed Livebook, you can configure the default Livebook location where you want to open notebooks.
Livebook up Checking status We can't reach this Livebook (but we saved your preference anyway)
Run notebook

Not yet? Install Livebook in just a minute

Livebook is open source, free, and ready to run anywhere.

Run on your machine

with Livebook Desktop

Run in the cloud

on select platforms

To run on Linux, Docker, embedded devices, or Elixir’s Mix, check our README.

PLATINUM SPONSORS
SPONSORS