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 characteristicsread/1
which returns the value of the characteristic associated with the given IDwrite/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).
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.
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)