AoiHashi – ESP32 bluetooth serial module

AoiHashi is a Bluetooth serial module based on the ESP32. It basically emulates the behavior of other well known modules like the HC-05 by creating a peer-to-peer connection which forwards a single UART port by using the Bluetooth SPP (Serial Port) profile. The UART baud rate is auto detected upon reception and adjusted on-thy-fly. This enables each transceiver to use a different baud rate than it’s remote counterpart.

The UART configuration itself can be set by modifying the header config.hpp. The important part is setting the right RX and TX pins you’d like to use. Also the default baud rate after reset can be adjusted. From what I’ve red the ESP32 should support up to 5MBaud, but I can’t confirm that since I currently don’t own a board which would support that.

Since I’ve found the ESP-IDF Bluetooth examples to be terribly undocumented I’d also like to share some insights on how the Bluetooth connection is established. I’m going to focus on Bluetooth Classic since that’s what the SPP profile is specified for.

Setting up a SPP connection is actually a two step process:

  1. Running GAP, the Generic Access Profile, which controls connections by handling advertising, discovery, authentication and pairing.
  2. Running SPP which controls the serial connection by handling client-server connection buildup and things like receiving and transmitting.

To start GAP app_main (the ESP-IDF application entry point) calls a function called bt_init which initializes and enables the Bluetooth controller and the Bluedroid stack. Subsequently It then calls the function bt_gap_init which

  • Sets some pairing parameters
  • Switches the device into a discoverable state
  • Registers a callback
  • Starts own device discovery
void bt_gap_init() {
  // Set default parameters for Secure Simple Pairing
  esp_bt_sp_param_t param_type = ESP_BT_SP_IOCAP_MODE;
  esp_bt_io_cap_t iocap = ESP_BT_IO_CAP_IO;
  esp_bt_gap_set_security_param(param_type, &iocap, sizeof(uint8_t));

  // Set default parameters for Legacy Pairing
  // Use variable pin, input pin code when pairing
  esp_bt_pin_type_t pin_type = ESP_BT_PIN_TYPE_VARIABLE;
  esp_bt_pin_code_t pin_code;
  esp_bt_gap_set_pin(pin_type, 0, pin_code);

  esp_bt_dev_set_device_name(bt_dev_name);

  // Get own BT device address
  uint8_t const* adr{esp_bt_dev_get_address()};
  if (!adr) {
    ESP_LOGE(bt_gap_tag, "%s can't retrieve own address\n", __func__);
    return;
  }
  memcpy(own_bda, adr, sizeof(esp_bd_addr_t));
  char bda_str[18];
  ESP_LOGI(bt_gap_tag, "Own address: %s", bda2str(own_bda, bda_str, 18));

  // Set discoverable and connectable mode, wait to be connected
  esp_bt_gap_set_scan_mode(ESP_BT_SCAN_MODE_CONNECTABLE_DISCOVERABLE);

  // Register GAP callback function
  esp_bt_gap_register_callback(bt_app_gap_cb);

  // Start to discover nearby Bluetooth devices
  esp_bt_gap_start_discovery(
      ESP_BT_INQ_MODE_GENERAL_INQUIRY,
      random_interval(inquiry_duration_min, inquiry_duration_max),
      0);
}

Inside the registered callback with the signature void (esp_bt_gap_cb_event_t, esp_bt_gap_cb_param_t*) we can then check the event type to take further action.

static void bt_app_gap_cb(esp_bt_gap_cb_event_t event,
                          esp_bt_gap_cb_param_t* param) {
  switch (event) {
    // device discovery result event
    case ESP_BT_GAP_DISC_RES_EVT:
      break;

    case ...
  }
}

So what basically needs to happen here is that once we get a discovery event we check if the discovered device is the one we’re looking for and in case it is we pair with it. Of course there is a lot of boilerplate involved like decoding inquiry responses and what not but you’ll get the idea. Be warned that when a device starts discovery it must disable advertising. This means it’s usually a good idea to randomize the discovery time…

Now that we’ve successfully paired we can initialize and start SPP. Since SPP requires devices to pick either a master or slave role I simply assigned the role of master to the device with the higher Bluetooth address. Just as GAP SPP requires us to register a callback to handle events. This time the callback has the signature void (esp_spp_cb_event_t, esp_spp_cb_param_t*). Since based on the SPP role the callback needs to handle different events I’ve chosen to create two callbacks, one for the role of master and one for slave.

The event sequence is roughly

  1. At an SPP initialization event the master starts SPP discovery whereas the slave starts a server.
  2. The master gets a discovery complete event and connects to the server.
  3. In succession the master gets a SPP client open and the slave a SPP server open event.

From this time on data transfer over SPP is (in theory) possible by using the function esp_spp_write. In theory? Well… apparently the current Bluedroid implementation has some issues with the internal credit based flow control. In case of high throughput the transmission credits may run low and a congestion event is fired which can be received inside the SPP callback. The problem with that event is that you can’t trace back when the problem occurred and how much data you’ve lost since then. esp_spp_write itself never fails although it could actually return an error.

An (admittedly ugly) workaround is to manually set the transmit credit count every time before we use esp_spp_write. This is what I’ve done inside the Bluetooth transmit task.

static void bt_tx_task(void* pvHandle) {
  uint32_t const handle{(uint32_t const)pvHandle};

  for (;;) {
    esp_task_wdt_reset();

    // Receive ring buffer handle from queue
    RingbufHandle_t uart_buf{nullptr};
    if (!xQueueReceive(uart_queue, &uart_buf, portMAX_DELAY))
      continue;

    // Receive data from ring buffer
    uint8_t* data{nullptr};
    size_t len{};
    while (!(
        data = (uint8_t*)xRingbufferReceive(uart_buf, &len, pdMS_TO_TICKS(10))))
      vTaskDelay(pdMS_TO_TICKS(10));

    // Dirty workaround to keep SPP from starving from ugly flow control bug
    for (auto i{0}; i < MAX_RFC_PORTS; ++i)
      rfc_cb.port.port[i].credit_tx = 10;

    // Write data to SPP
    while (esp_spp_write(handle, len, data) != ESP_OK)
      vTaskDelay(pdMS_TO_TICKS(10));

    // Return item from ring buffer
    vRingbufferReturnItem(uart_buf, (void*)data);
  }
}

The struct rfc_cb is defined inside the header rfc_int.h. In order to include the header we must add quite a few folders from the ESP-IDF framework to the makefile.

CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/"
CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/api/include/"
CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/common/include/"
CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/osi/include/"
CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/stack/include/"
CPPFLAGS += -I"$(IDF_PATH)/components/bt/bluedroid/stack/rfcomm/include/"

So far this workaround was able to prevent congestion events but I really hope Espressif is going to fix this in the future.

Update 18/10/2021: As of version 4.2 of the ESP-IDF framework the described workaround was no longer necessary.