Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Rustler BLE playground

livebooks/ble_demo.livemd

Rustler BLE playground

Mix.install([
  {:kino, "~> 0.15.3"},
  # , ref: "536ce4f231c14bdf6e7746b726e9fa0d82df393f"
  # {:kino, github: "livebook-dev/kino"},
  {:rustler_btleplug, "~> 0.0.14-alpha"}
])

Section

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: &amp;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
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