<!-- 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
```