Powered by AppSignal & Oban Pro

Bluetooth LE

priv/samples/09_bluetooth.livemd

Bluetooth LE

Overview

The Bluetooth radio shares the WCN3680 chip with Wi-Fi. The kernel talks to it and exposes it as hci0; from there BlueHeron, a Bluetooth LE stack written in Elixir, drives it directly. There is no BlueZ daemon in between: BlueHeron sends the raw HCI commands itself.

Layer What it does
WCN3680 The radio
btqcomsmd (kernel) Carries HCI packets to the chip, exposes hci0
BlueHeron.HCI.Transport.HCISocket Opens hci0 for exclusive use
BlueHeron Scanning, advertising, GATT services

This notebook covers Bluetooth LE (low energy): beacons, sensors, fitness trackers, phone apps. Classic Bluetooth (headphones, call headsets, serial adapters) isn't available: the chip supports it, but BlueHeron only speaks Bluetooth LE.

Install a BLE scanner on your own phone to follow along, for example nRF Connect (Android and iOS).

Is the radio ready?

BlueHeron starts with the firmware and sets the controller up in the background:

BlueHeron.HCI.Transport.setup_complete?()
{:ok, address} = BlueHeron.HCI.Transport.get_setup_param(:bd_addr)
to_string(address)

Scan for nearby devices

Scanning means listening for advertisements: short packets that BLE devices broadcast a few times per second. BlueHeron forwards every HCI event to the processes that subscribe to it.

alias BlueHeron.HCI.Command.LEController
alias BlueHeron.HCI.Event.LEMeta.AdvertisingReport

BlueHeron.Registry.subscribe()

{:ok, _} = BlueHeron.HCI.Transport.send_hci(%LEController.SetScanParameters{le_scan_type: 0x01})
{:ok, _} = BlueHeron.HCI.Transport.send_hci(%LEController.SetScanEnable{le_scan_enable: true})

collect = fn collect, found, deadline ->
  wait = max(deadline - System.monotonic_time(:millisecond), 0)

  receive do
    {:HCI_EVENT_PACKET, %AdvertisingReport{devices: devices}} ->
      found =
        Enum.reduce(devices, found, fn device, found ->
          name =
            Enum.find_value(device.data, fn
              {"Complete Local Name", name} -> name
              {"Shortened Local Name", name} -> name
              _ -> nil
            end)

          # The signal strength arrives as one byte; read it as a signed number.
          <<rssi::signed>> = <<device.rss>>

          Map.update(found, device.address, %{name: name, rssi: rssi}, fn seen ->
            %{name: seen.name || name, rssi: max(seen.rssi, rssi)}
          end)
        end)

      collect.(collect, found, deadline)
  after
    wait -> found
  end
end

# Listen for ten seconds.
found = collect.(collect, %{}, System.monotonic_time(:millisecond) + 10_000)

{:ok, _} = BlueHeron.HCI.Transport.send_hci(%LEController.SetScanEnable{le_scan_enable: false})

for {address, %{name: name, rssi: rssi}} <- Enum.sort_by(found, fn {_, d} -> -d.rssi end) do
  %{
    address: BlueHeron.Address.parse(address) |> to_string(),
    name: name || "",
    signal: "#{rssi} dBm"
  }
end
|> Kino.DataTable.new()

signal is how strongly each device was heard: around -40 dBm is right next to the phone, -90 dBm is at the edge of range. Many devices don't advertise a name.

Advertise the phone

Now the Fairphone broadcasts its own advertisements. Look for Fairphone-Nerves in the scanner app on your phone.

alias BlueHeron.AdvertisingData

flags = AdvertisingData.flags(le_general_discoverable_mode: true, br_edr_not_supported: true)
name = AdvertisingData.complete_name("Fairphone-Nerves")

BlueHeron.Broadcaster.stop_advertising()
BlueHeron.Broadcaster.set_advertising_data(flags <> name)
BlueHeron.Broadcaster.start_advertising()

Share the battery level

A GATT service is what other devices read once they connect. Bluetooth defines a standard Battery Service (0x180F) with a Battery Level characteristic (0x2A19), one byte from 0 to 100. Scanner apps know it and show it as a percentage.

alias BlueHeron.GATT.{Characteristic, Service}

battery_level = fn ->
  "/sys/class/power_supply/qg-battery/capacity"
  |> File.read!()
  |> String.trim()
  |> String.to_integer()
end

gap =
  Service.new(%{
    id: :gap,
    type: 0x1800,
    characteristics: [
      Characteristic.new(%{id: {:gap, :device_name}, type: 0x2A00, properties: 0b0000010}),
      Characteristic.new(%{id: {:gap, :appearance}, type: 0x2A01, properties: 0b0000010})
    ],
    read: fn
      {:gap, :device_name} -> "Fairphone-Nerves"
      # Appearance 0x0040: generic phone
      {:gap, :appearance} -> <<0x0040::little-16>>
    end
  })

gatt =
  Service.new(%{
    id: :gatt,
    type: 0x1801,
    characteristics: [
      Characteristic.new(%{id: {:gatt, :service_changed}, type: 0x2A05, properties: 0b0100000})
    ]
  })

battery =
  Service.new(%{
    id: :battery,
    type: 0x180F,
    characteristics: [
      Characteristic.new(%{id: {:battery, :level}, type: 0x2A19, properties: 0b0000010})
    ],
    read: fn {:battery, :level} -> <<battery_level.()>> end
  })

BlueHeron.Peripheral.add_service(gap)
BlueHeron.Peripheral.add_service(gatt)
BlueHeron.Peripheral.add_service(battery)

In nRF Connect, connect to Fairphone-Nerves, open Battery Service and read Battery Level: it matches the battery notebook.

properties: 0b0000010 marks a characteristic as readable. Write your own service the same way: pick a random 128-bit UUID for type, return any binary from read, and add a write function to receive data from the other phone.

Pair with a laptop

The Fairphone can also pair like any Bluetooth accessory. It uses "Just Works" pairing, so there is no code to type.

With the battery service registered (the cell above) and the phone advertising, open your laptop's Bluetooth settings and set up Fairphone-Nerves. On Linux with GNOME, the phone's battery then shows up under Settings → Power, or with upower -d in a terminal.

Services only live until the phone reboots: after a reboot, run the battery cell again before connecting. If an earlier attempt left the laptop stuck on "not set up", remove the device on the laptop first.

Stop advertising

BlueHeron.Broadcaster.stop_advertising()