BLE + Elixir

BLE + Elixir

My absolute favorite part of Elixir/Erlang is Binary Pattern Matching. While working on the new BLE Library for Elixir, I've had to implement a ton of protocols that are part of the Bluetooth specification. I want to take a moment to appreciate how easy it is to implement decoding/encoding of binary data in Elixir.

Decoding and Encoding HCI, ACL, L2CAP

These are the 3 basic building blocks of the Bluetooth stack. Each builds on top of the last. I'll give an overview of each protocol, and the Elixir code required to encode or decode each of them. You'll find that most of the decoding/encoding can be done in a single line.

HCI

HCI stands for Host Controller Interface. Basically, it's how the Host Controls the Bluetooth Interface.

When you pair a headset to your mobile phone, the mobile phone is the host, and it sends an HCI command to the controller in the phones's silicone somewhere. When you put your headset in pairing mode, it will broadcast an HCI event to your phone. These two core concepts, command and event are the bulk of HCI. Packets are composed of an opcode which is a little endian 16 bit number, the length of the data, and finally, the data itself. Decoding/Encoding in Elixir is fairly simple:

@doc "Decode an HCI packet"
def decode(<<opcode::little-16, length, data::binary-size(length)>>) do
  # route via opcode to individual modules. Each opcode's data is
  # different.
  %__MODULE__{opcode: opcode, data: data}
end

@doc "Encode an HCI packet back into a binary"
def encode(%__MODULE__{opcode: opcode, data: data}) do
  length = byte_size(data)
  <<opcode::little-16, length::8, data::binary-size(length)>>
end

ACL

ACL stands for Asynchronous Connection-Less. When you change
the color of a Bluetooth enabled LED, it is sending ACL data. ACL has a lot of uses, but in BLE specifically, it is what allows you to quickly send and receive data without the clunky pairing mechanism of Bluetooth Classic.

The packet structure consists of a handle which is a 16 bit, little endian integer that gets generated by the device after a open connection command (yes, you have to open a connection to send connection-less data, naming is hard right), then there are two flags encoded into a 8 bit integer, after that a length field, and finally the ACL data payload.

@doc "Decode an HCI ACL data packet"
def decode(<<handle::little-16, pb::2, bc::2, length::little-16, data::binary-size(length)>>) do
  # the data inside an ACL packet is contextual. Most uses for BLE
  # will use the next protocol we describe, L2CAP
  %__MODULE__{handle: handle, flags: %{broadcast: bc, packet_boundry: pb}, data: data}
end

@doc "Encode an HCI ACL data packet"
def encode(%__MODULE__{handle: handle, flags: flags, data: data}) do
  length = byte_size(data)
  <<handle::little-16, flags.packet_boundry::4, flags.broadcast::4, length::little-16, data::binary-size(length)>>
end

L2CAP

L2CAP stands for Logical Link Control and Adaptation Layer Protocol. This is a format laid on top of ACL data. it is important for routing of information and multiplexing data inside the confines of ACL.

The packet format consists of a 16 bit little endian length field, a channel id and some data.

@doc "Decode a binary L2Cap Packet"
def decode(<<length::little-16, cid::little-16, data::binary-size(length)>>) do
  # Channel is used for routing. In BLE there are special channel
  # IDs that tell you how to decode `data`. For example, CID of 0x4
  # is used for "Generic Attribute Protocol"
  %__MODULE__{channel_id: cid, data: data}
end

@doc "Encode a L2CAP packet back to binary"
def encode(%__MODULE__{channel_id: cid, data: data}) do
  length = byte_size(data)
  <<length::little-16, cid::little-16, data::binary-size(length)>>
end

Debugging and Logging with HCIDump and BTSnoop

While building BlueHeron, we found it useful to collect log dumps from various sources and make sure that the library could process them in a meaningful way. I'll describe both of these protocols, and give a simple implementation of both.

HCIDump

HCIDump is a format created by the Bluez project that many other Bluetooth implementations use. Blue Heron is no exception. It allows for storing a series of Bluetooth HCI packets, ordered, timestamped and classified on disk. Decoding can be implemented with a recursive function in Elixir:

@doc """
Decode an array of HCIDump packets
"""
def decode(<<length::32, sec::32, us::32, type::8, rest::binary>>, acc) do
  # header is 17 octets = len(sec) + len(us) + len(type)
  payload_length = length - 13 + 4
  # extract the payload
  <<payload::binary-size(payload_length), rest::binary>> = rest
  acc = [%__MODULE__{tv_sec: sec, tv_us: us, type: decode_type(type), payload: payload} | acc]
  decode(rest, acc)
end

def decode(<<>>, acc), do: Enum.reverse(acc)

defp decode_type(0x00), do: :HCI_COMMAND_DATA_PACKET
defp decode_type(0x03), do: :HCI_ACL_DATA_PACKET
defp decode_type(0x02), do: :HCI_ACL_DATA_PACKET
defp decode_type(0x09), do: :HCI_SCO_DATA_PACKET
defp decode_type(0x08), do: :HCI_SCO_DATA_PACKET
defp decode_type(0x01), do: :HCI_EVENT_PACKET
defp decode_type(0xFC), do: :LOG_MESSAGE_PACKET

Encoding is equally as easy:

@doc """
Encodes a HCIDump packet. `direction` is one of `:in` or `:out`.
"""
def encode(%__MODULE__{type: type} = pkt, direction) do
  # header is 17 octets = len(sec) + len(us) + len(type)
  payload_length = 17 + byte_size(pkt.payload)
  type = encode_type(type, direction)
  <<payload_length::32, pkt.tv_sec::32, pkt.tv_us::32, type::8, payload::binary-size(payload_length)>>
end

defp encode_type(:HCI_COMMAND_DATA_PACKET, _), do: 0x00
defp encode_type(:HCI_ACL_DATA_PACKET, :in), do: 0x03
defp encode_type(:HCI_ACL_DATA_PACKET, :out), do: 0x02
defp encode_type(:HCI_SCO_DATA_PACKET, :in), do: 0x09
defp encode_type(:HCI_SCO_DATA_PACKET, :out), do: 0x08
defp encode_type(:HCI_EVENT_PACKET, _), do: 0x01
defp encode_type(:LOG_MESSAGE_PACKET, _), do: 0xFC

BTSnoop V1

BTSnoop is another protocol used for storing HCI traffic. What makes it appealing is Android devices can save BTSnoop dump files as recorded by the user using their device. This is particularly useful when reverse engineering undocumented BLE devices because Wireshark can import and export these dumps. Being able to decode them in Elixir essentially allows for playback of logs where you can easily view each command in Elixir terms.

The file format consists of a header and an array of packets.

@doc "Decode the contents of a BTSnoop file"
def decode(<<"btsnoop", 0x0, 1::32, @hci_uart_type::32, packets::binary>>) do
  packets = decode_packets(packets, [])
  %__MODULE__{version: 1, type: :uart, packets: packets}
end

def decode_packets(
      <<_original_length::32, packet_data_length::32, direction::1, type::1, _reserved::30,
        drops::32, micros::signed-64, data::binary-size(packet_data_length), rest::binary>>,
      acc
    ) do
  direction = if direction == 0, do: :sent, else: :received
  type = if type == 0, do: :data, else: :command

  packet = %Packet{
    timestamp: micros,
    drops: drops,
    direction: direction,
    type: type
  }

  decode_packets(rest, [decode_payload(packet, data) | acc])
end

def decode_packets(<<>>, acc), do: Enum.reverse(acc)

# I've omitted other packet types for simplicity.
def decode_payload(packet, <<@hci_acl_type::8, data::binary>>) do
  payload = BlueHeron.ACL.decode(data)
  %{packet | payload: payload, type: :HCI_ACL_DATA_PACKET}
end

Conclusions

I tried to find C implementations of some of these protocols for this post, but ultimately gave up because I couldn't find anything simple enough to make a fair comparison. Binary Pattern Matching in Elixir is a super power that allows for you to get binary data into a format that can be used in your Elixir codebase quickly with minimal fuss. This allows you to focus more on your business logic and implementations rather than debugging a decoder or encoder.

I hope these real world examples show just how easy it is to get started with Binary Pattern Matching in Elixir.

Tags :