Skip to content

Instantly share code, notes, and snippets.

@trarbr
Created September 14, 2021 07:36
Show Gist options
  • Save trarbr/42fc7f7e5d51ddd8a68d4a42e244c6ac to your computer and use it in GitHub Desktop.
Save trarbr/42fc7f7e5d51ddd8a68d4a42e244c6ac to your computer and use it in GitHub Desktop.
A BLE Peripheral Livebook example (using Livebook on Nerves and Blue Heron)

Peripheral

Library code

First we define the Characteristic and Service modules. Users of the library use these to declare the services that their device expose.

defmodule BlueHeron.GATT.Characteristic do
  defstruct [:id, :type, :properties, :permissions, :descriptors, :handle, :value_handle]

  # id is required, can be any term, but must be unique within the services() function
  # type is required, must be either 16 or 128 bit UUID
  # properties is required, must be an integer between 0 and 255
  # permissions is required, but I don't know the format yet
  # descriptors is required, but I don't know the format yet
  # handle and value_handle should not be specified by the user
  def new(args) do
    args = Map.take(args, [:id, :type, :properties, :permissions, :descriptors])
    struct!(__MODULE__, args)
  end
end

defmodule BlueHeron.GATT.Service do
  defstruct [
    :id,
    :primary?,
    :type,
    :included_services,
    :characteristics,
    :handle,
    :end_group_handle
  ]

  # id is required, can be any term, but must be unique within the services() function
  # primary? defaults to true, set it to false if it should only show up as an included service  
  # type is required, must be either 16 or 128 bit UUID
  # included_services is a list of service ID's to be included in the definition of this service
  # characteristics is a list of characteristics
  # handle and end_group_handle should not be specified by the user
  def new(args) do
    args =
      Map.put_new(args, :primary?, true)
      |> Map.take([:id, :primary?, :type, :included_services, :characteristics])

    struct!(__MODULE__, args)
  end
end

Next we define the GATT server. It implements the GATT procedures described in the spec, like "Discover All Primary Services", etc. The handle/2 function takes a server-struct (the current state of the GATT server), and an ATT request, and then dispatches to functions named after the matching procedure.

The GATT server requires a callback module to be passed in init/1 (called handler). The callback module must expose functions the following functions:

  • profile/0, which returns a list of services and characteristics
  • read/1 which returns the value of the characteristic associated with the given ID
  • write/2 which accepts a new value for the characteristic associated with the given ID

This callback module will be supplied by the user of the library.

defmodule BlueHeron.GATT.Server do
  alias BlueHeron.ATT.{
    ErrorResponse,
    FindInformationRequest,
    PrepareWriteRequest,
    PrepareWriteResponse,
    ExecuteWriteRequest,
    ExecuteWriteResponse,
    ReadBlobRequest,
    ReadBlobResponse,
    ReadByGroupTypeRequest,
    ReadByGroupTypeResponse,
    ReadByTypeRequest,
    ReadByTypeResponse,
    ReadRequest,
    ReadResponse,
    WriteRequest,
    WriteResponse
  }

  defstruct [:handler, :profile, :mtu, :write_handle, :write_buffer]

  @discover_all_primary_services 0x2800
  @find_included_services 0x2802
  @discover_all_characteristics 0x2803

  def init(handler) do
    profile = hydrate(handler.profile())

    %__MODULE__{
      handler: handler,
      profile: profile,
      mtu: 23,
      write_handle: nil,
      write_buffer: ""
    }
  end

  def handle(state, request) do
    IO.inspect(request, label: :request)

    {state, response} =
      case request do
        %ReadByGroupTypeRequest{uuid: @discover_all_primary_services} ->
          discover_all_primary_services(state, request)

        %ReadByTypeRequest{uuid: @find_included_services} ->
          find_included_services(state, request)

        %ReadByTypeRequest{uuid: @discover_all_characteristics} ->
          discover_all_characteristics(state, request)

        %FindInformationRequest{} ->
          discover_all_characteristic_descriptors(state, request)

        %ReadRequest{} ->
          read_characteristic_value(state, request)

        %ReadBlobRequest{} ->
          read_long_characteristic_value(state, request)

        %WriteRequest{} ->
          write_characteristic_value(state, request)

        %PrepareWriteRequest{} ->
          write_long_characteristic_value(state, request)

        %ExecuteWriteRequest{} ->
          write_long_characteristic_value(state, request)
      end

    IO.inspect(response, label: :response)
    {state, response}
  end

  def discover_all_primary_services(state, request) do
    services =
      Enum.filter(state.profile, fn service ->
        service.primary? and service.handle >= request.starting_handle and
          service.handle <= request.ending_handle
      end)

    case services do
      [] ->
        {state,
         %ErrorResponse{
           handle: request.starting_handle,
           request_opcode: request.opcode,
           error: :attribute_not_found
         }}

      [first | _] = services_in_range ->
        # TODO: Make sure the list of services returned is not too long
        services_of_type =
          case first.type <= 0xFFFF do
            true ->
              Enum.filter(services_in_range, fn service -> service.type <= 0xFFFF end)

            false ->
              Enum.filter(services_in_range, fn service -> service.type > 0xFFFF end)
          end

        attribute_data =
          Enum.map(services_of_type, fn service ->
            %ReadByGroupTypeResponse.AttributeData{
              handle: service.handle,
              end_group_handle: service.end_group_handle,
              uuid: service.type
            }
          end)

        {state, %ReadByGroupTypeResponse{attribute_data: attribute_data}}
    end
  end

  def find_included_services(state, request) do
    # TODO: Implement
    {state,
     %ErrorResponse{
       handle: request.starting_handle,
       request_opcode: request.opcode,
       error: :attribute_not_found
     }}
  end

  def discover_all_characteristics(state, request) do
    characteristics =
      state.profile
      |> Enum.flat_map(fn service -> service.characteristics end)
      |> Enum.filter(fn characteristic ->
        # TODO: Check if the handle range should be inclusive in both ends
        characteristic.handle >= request.starting_handle and
          characteristic.handle <= request.ending_handle
      end)

    case characteristics do
      [] ->
        {state,
         %ErrorResponse{
           handle: request.starting_handle,
           request_opcode: request.opcode,
           error: :attribute_not_found
         }}

      [first | _] = characteristics_in_range ->
        # TODO: Make sure the list of characteristics returned is not too long
        characistics_of_type =
          case first.type <= 0xFFFF do
            true ->
              Enum.filter(characteristics_in_range, fn characteristic ->
                characteristic.type <= 0xFFFF
              end)

            false ->
              Enum.filter(characteristics_in_range, fn characteristic ->
                characteristic.type > 0xFFFF
              end)
          end

        attribute_data =
          Enum.map(characistics_of_type, fn characteristic ->
            %ReadByTypeResponse.AttributeData{
              handle: characteristic.handle,
              uuid: characteristic.type,
              characteristic_properties: characteristic.properties,
              characteristic_value_handle: characteristic.value_handle
            }
          end)

        {state, %ReadByTypeResponse{attribute_data: attribute_data}}
    end
  end

  def discover_all_characteristic_descriptors(state, request) do
    # TODO: Implement
    {state,
     %ErrorResponse{
       handle: request.starting_handle,
       request_opcode: request.opcode,
       error: :attribute_not_found
     }}
  end

  def read_characteristic_value(state, request) do
    id = find_characteristic_id(state.profile, request.handle)
    value = state.handler.read(id)

    # TODO: Might want to cache the value if it's longer than MTU - 1, and then
    # serve the read_long_characteristic_value requests from that cache
    # in order to avoid inconsistent reads if the value is updated during the read operation.
    value =
      if byte_size(value) > state.mtu - 1 do
        :binary.part(value, 0, state.mtu - 1)
      else
        value
      end

    {state, %ReadResponse{value: value}}
  end

  def read_long_characteristic_value(state, request) do
    id = find_characteristic_id(state.profile, request.handle)
    value = state.handler.read(id)

    value =
      if byte_size(value) - request.offset > state.mtu - 1 do
        :binary.part(value, request.offset, state.mtu - 1)
      else
        :binary.part(value, request.offset, byte_size(value) - request.offset)
      end

    {state, %ReadBlobResponse{value: value}}
  end

  def write_characteristic_value(state, request) do
    id = find_characteristic_id(state.profile, request.handle)
    state.handler.write(id, request.value)

    {state, %WriteResponse{}}
  end

  def write_long_characteristic_value(state, %PrepareWriteRequest{} = request) do
    # TODO: Probably want to store a list of write-operations and only materealize the resulting binary
    # when receiving the ExecuteWriteRequest - in case the PrepareWriteRequest do not arrive in order.
    state = %{
      state
      | write_handle: request.handle,
        write_buffer: state.write_buffer <> request.value
    }

    {state,
     %PrepareWriteResponse{
       handle: request.handle,
       offset: request.offset,
       value: request.value
     }}
  end

  def write_long_characteristic_value(state, %ExecuteWriteRequest{flags: 1}) do
    id = find_characteristic_id(state.profile, state.write_handle)
    state.handler.write(id, state.write_buffer)
    state = %{state | write_handle: nil, write_buffer: ""}

    {state, %ExecuteWriteResponse{}}
  end

  def write_long_characteristic_value(state, %ExecuteWriteRequest{flags: 0}) do
    state = %{state | write_handle: nil, write_buffer: ""}

    {state, %ExecuteWriteResponse{}}
  end

  defp hydrate(profile) do
    # To each service, assign a handle and end handle
    # assign handle to characteristics as well
    # Maybe also create a characteristic.value_handle => characteristic.id
    # TODO: Check that ID's are unique
    # TODO: Check the services with primary?: false are included in other services
    {_next_handle, profile} =
      Enum.reduce(profile, {1, []}, fn service, {next_handle, acc} ->
        service_handle = next_handle

        {next_handle, characteristics} =
          assign_characteristic_handles(service.characteristics, next_handle + 1)

        service = %{
          service
          | handle: service_handle,
            end_group_handle: next_handle - 1,
            characteristics: characteristics
        }

        {next_handle, [service | acc]}
      end)

    Enum.reverse(profile)
  end

  defp assign_characteristic_handles(characteristics, starting_handle) do
    {next_handle, characteristics} =
      Enum.reduce(characteristics, {starting_handle, []}, fn characteristic, {next_handle, acc} ->
        characteristic = %{characteristic | handle: next_handle, value_handle: next_handle + 1}
        {next_handle + 2, [characteristic | acc]}
      end)

    {next_handle, Enum.reverse(characteristics)}
  end

  defp find_characteristic_id(profile, characteristic_value_handle) do
    profile
    |> Enum.flat_map(fn service -> service.characteristics end)
    |> Enum.find_value(fn characteristic ->
      if characteristic.value_handle == characteristic_value_handle, do: characteristic.id
    end)
  end
end

Lastly, the Peripheral. This is a :gen_statem process. In the start_link/2 function it takes the GATT server's callback module as an argument, which it uses in its init/1 function to initialize the state of the GATT server.

The Peripheral API lets the user specify advertising parameters and data, and turn advertising on and off.

The Peripheral should also expose a GATT client API.

defmodule BlueHeron.Peripheral do
  alias BlueHeron.HCI.Command.LEController.{
    SetAdvertisingParameters,
    SetAdvertisingData,
    SetAdvertisingEnable
  }

  alias BlueHeron.HCI.Event.{CommandComplete, DisconnectionComplete}
  alias BlueHeron.HCI.Event.LEMeta.ConnectionComplete

  alias BlueHeron.{ACL, L2Cap}

  alias BlueHeron.GATT

  @behaviour :gen_statem

  defstruct [:ctx, :controlling_process, :caller, :conn_handle, :gatt_server]

  def start_link(context, gatt_server) do
    :gen_statem.start_link(__MODULE__, [context, gatt_server, self()], [])
  end

  def set_advertising_parameters(pid, params) do
    :gen_statem.call(pid, {:set_parameters, params})
  end

  def set_advertising_data(pid, data) do
    :gen_statem.call(pid, {:set_advertising_data, data})
  end

  def start_advertising(pid) do
    :gen_statem.call(pid, :start_advertising)
  end

  def stop_advertising(pid) do
    :gen_statem.call(pid, :stop_advertising)
  end

  @impl :gen_statem
  def callback_mode(), do: :state_functions

  @impl :gen_statem
  def init([ctx, gatt_handler, controlling_process]) do
    :ok = BlueHeron.add_event_handler(ctx)
    gatt_server = GATT.Server.init(gatt_handler)

    data = %__MODULE__{
      controlling_process: controlling_process,
      ctx: ctx,
      caller: nil,
      conn_handle: nil,
      gatt_server: gatt_server
    }

    {:ok, :wait_working, data, []}
  end

  def wait_working(:info, {:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, data) do
    {:next_state, :ready, data}
  end

  def wait_working(:info, {:HCI_EVENT_PACKET, _}, _data) do
    :keep_state_and_data
  end

  def wait_working({:call, _from}, _call, _data), do: {:keep_state_and_data, [:postpone]}

  def ready({:call, from}, {:set_parameters, params}, data) do
    command = SetAdvertisingParameters.new(params)

    {:ok, %CommandComplete{return_parameters: %{status: 0}}} =
      BlueHeron.hci_command(data.ctx, command)

    {:keep_state_and_data, [{:reply, from, :ok}]}
  end

  def ready({:call, from}, {:set_advertising_data, adv_data}, data) do
    command = SetAdvertisingData.new(advertising_data: adv_data)

    {:ok, %CommandComplete{return_parameters: %{status: 0}}} =
      BlueHeron.hci_command(data.ctx, command)

    {:keep_state_and_data, [{:reply, from, :ok}]}
  end

  def ready({:call, from}, :start_advertising, data) do
    command = SetAdvertisingEnable.new(advertising_enable: true)

    {:ok, %CommandComplete{return_parameters: %{status: 0}}} =
      BlueHeron.hci_command(data.ctx, command)

    {:next_state, :advertising, data, [{:reply, from, :ok}]}
  end

  def ready(:info, {:HCI_EVENT_PACKET, _event}, _data) do
    :keep_state_and_data
  end

  def advertising({:call, from}, :stop_advertising, data) do
    command = SetAdvertisingEnable.new(advertising_enable: false)

    {:ok, %CommandComplete{return_parameters: %{status: 0}}} =
      BlueHeron.hci_command(data.ctx, command)

    {:keep_state, %{data | caller: from}}
  end

  def advertising(:info, {:HCI_EVENT_PACKET, %ConnectionComplete{} = event}, data) do
    {:next_state, :connected, %{data | conn_handle: event.connection_handle}, []}
  end

  def advertising(:info, {:HCI_EVENT_PACKET, _event}, _data) do
    # TODO: Handle scan request
    # and maybe other events as well
    :keep_state_and_data
  end

  def connected(:info, {:HCI_ACL_DATA_PACKET, %ACL{data: %L2Cap{data: request}}}, data) do
    {gatt_server, response} = GATT.Server.handle(data.gatt_server, request)

    acl_response = %ACL{
      handle: data.conn_handle,
      flags: %{bc: 0, pb: 0},
      data: %L2Cap{
        cid: 0x04,
        data: response
      }
    }

    BlueHeron.acl(data.ctx, acl_response)

    {:keep_state, %{data | gatt_server: gatt_server}, []}
  end

  def connected(:info, {:HCI_EVENT_PACKET, %DisconnectionComplete{}}, data) do
    {:next_state, :ready, data}
  end
end

So that's all the library code (for now).

Application code

In my exciting Nerves application, I have an Agent which acts as a key-value store. Each key is supposed to hold a map that can be serialized as JSON.

defmodule MyApp.MyState do
  use Agent

  def start_link() do
    Agent.start_link(fn -> %{"foo" => %{"bar" => "baz"}} end, name: __MODULE__)
  end

  def get(agent \\ __MODULE__, key) do
    Agent.get(agent, fn config -> Map.get(config, key) end)
  end

  def put(agent \\ __MODULE__, key, value) do
    Agent.update(agent, fn config -> Map.put(config, key, value) end)
  end
end

And then I implement the GATT server callback module. The profile/0 lists two services:

  • The GAP service (type 0x1800), which is mandatory according to the spec
  • A custom service (type 0xBB5D5975D8E4853998F51335CDFFE9A)

I have then implemented a few read and write functions. Note that I'm missing a read function for the :appearance attribute.

defmodule MyApp.MyState.GATTHandler do
  # TODO: Declare a behaviour, which this module implements
  # It must have the functions profile/0, read/1 and write/2
  alias BlueHeron.GATT.{Characteristic, Service}

  def profile() do
    [
      Service.new(%{
        id: :gap,
        primary?: true,
        # 0x1800 => GAP service UUID
        type: 0x1800,
        characteristics: [
          Characteristic.new(%{
            # 0x2A00 => Device Name
            id: :device_name,
            type: 0x2A00,
            properties: 0b0000010,
            permissions: :ro,
            descriptors: []
          }),
          Characteristic.new(%{
            # 0x2A01 => Appearance
            id: :appearance,
            type: 0x2A01,
            properties: 0b0000010,
            permissions: :ro,
            descriptors: []
          })
        ]
      }),
      Service.new(%{
        id: MyApp.MyState,
        primary?: true,
        type: 0xBB5D5975D8E4853998F51335CDFFE9A,
        characteristics: [
          Characteristic.new(%{
            id: {MyApp.MyState, "foo"},
            type: 0xF018E00E0ECE45B09617B744833D89BA,
            properties: 0b0001010,
            permissions: :rw,
            descriptors: []
          })
        ]
      })
    ]
  end

  def read(:device_name) do
    "my-device"
  end

  def read({MyApp.MyState, key}) do
    MyApp.MyState.get(key)
    |> Jason.encode!()
  end

  def write({MyApp.MyState, key}, value) do
    value = Jason.decode!(value)
    MyApp.MyState.put(key, value)
  end
end

And now I can start my peripheral! I configure advertising, and turn it on.

Note that the advertising data is configured as raw binaries, this should be changed to user-friendly structs.

Also note that the advertising data used in this example has been carefully laid out so it can fit into legacy advertising packets.

MyApp.MyState.start_link()

device = "ttyAMA0"

{:ok, ctx} =
  BlueHeron.transport(%BlueHeronTransportUART{device: device, uart_opts: [speed: 115_200]})

{:ok, p} = BlueHeron.Peripheral.start_link(ctx, MyApp.MyState.GATTHandler)

BlueHeron.Peripheral.set_advertising_parameters(p, %{})

advertising_data =
  IO.iodata_to_binary([
    # Structure:
    # <<length, ad_type, data>>
    # Flags: BR/EDR not supported, GeneralConnectable
    <<0x02, 0x01, 0b00000110>>,
    # Complete Local Name
    <<0x09, 0x09, "APP/1234">>,
    # Incomplete List of 128-bit Servive UUIDs - this advertises the MyApp.MyState service
    <<0x11, 0x06,
      <<11, 181, 213, 151, 93, 142, 72, 83, 153, 143, 81, 51, 92, 223, 254, 154>>::binary>>
  ])

BlueHeron.Peripheral.set_advertising_data(p, advertising_data)

BlueHeron.Peripheral.start_advertising(p)
MyApp.MyState.put("foo", "foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")

At this stage I can fire up an app (like nRF Connect) and scan for devices. I should see APP/1234 in the list. If I connect, the app will start service discovery, and find the services mentioned above. I can read and write to the foo characteristic.

Notes

UUIDs are weird. Mostly because what's displayed in Wireshark is not what's displayed in the nRF Connect app. I need to figure out the correct byte ordering for those.

If the app disconnects, I need to start advertising again before I can connect again.

BlueHeron.Peripheral.start_advertising(p)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment