Skip to content

Concepts First

  • HID over GATT: a protocol that carries keyboard/mouse events over a BLE GATT channel; the peer converts them into standard USB HID reports, so the host sees a Bluetooth keyboard/mouse.
  • Custom GATT channels: this solution defines three logical channels — voice, key and user — carried over a private BLE protocol with framed packets.
  • INCBIN: embeds a binary file (e.g., PCM audio) directly into the firmware image, so no separate flash write is needed.
  • PCM: uncompressed audio samples; this solution uses a fixed format of 16 kHz, mono, 16-bit little-endian (s16le).

Example Overview

  • This page covers the official ble_hid_cli_sender example (examples/solution/TvBox/ble_hid_cli_sender), the lightweight peer board in the TvBox solution: it only runs the BLE custom GATT transport and a Shell — no USB device, ECM or UAC.
  • Pairs with examples/solution/TvBox/usb_ecm_audio-notify: the peer advertises as USB-ECM-AUD; this board scans, connects, then sends framed data over the private protocol.
  • Supported chip: BL616CL only (BLE-only), build with CHIP=bl616cl.
  • Two Shell commands:
    • ble_hid <text>: sends text over the KEY channel; the peer outputs it as USB HID keyboard events;
    • ble_voice_bin: sends the embedded audio.pcm (16 kHz mono s16le) in 15 ms frames over the VOICE channel.

Operation Steps

1
Enter the example directory

Enter the SDK’s ble_hid_cli_sender example directory (prerequisite: set up the environment with Quick Start (Linux) or Windows):

cd examples/solution/TvBox/ble_hid_cli_sender
2
Build the project

This example targets the BLE-only BL616CL chip, so it must be built with bl616cl:

make CHIP=bl616cl BOARD=bl616cldk
3
Flash the firmware

Hold BOOT, tap EN/RST to enter download mode, then flash:

make flash CHIP=bl616cl COMX=/dev/ttyUSB0
4
Prepare the peer board

Run the TvBox usb_ecm_audio-notify example (device name USB-ECM-AUD) on another board; this board scans for it and connects automatically.

5
Run and verify: send keyboard text

Open the serial terminal at 2000000 baud; once connected, type the command below (the text is sent over the KEY channel and the peer converts it into USB HID keyboard reports):

ble_hid hello
6
(Optional) Send the embedded voice

Place a 16 kHz mono s16le PCM file named audio.pcm next to main.c, rebuild and reflash, then run:

ble_voice_bin

Code Execution Flow

APIs Used by the Example

usb_ecm_ble_stack_init()

Initialize the BLE stack and start the scan/connect flow (automatically connects to the peer named USB-ECM-AUD).

Parameters: none

Return value: none

usb_ecm_ble_send_channel(channel, data, len)

Send data over a specified logical channel.

Parameters:

  • channel: channel enum — USB_ECM_BLE_CHANNEL_VOICE / KEY / USER
  • data: data to send
  • len: data length

Return value: 0 on success; negative on failure

usb_ecm_ble_set_channel_data_callback(channel, cb)

Register a receive callback for a channel; peer replies/notifications are printed to the Shell.

Parameters:

  • channel: channel enum
  • cb: receive callback

Return value: none

INCBIN(symbol, "file")

Embed audio.pcm into the firmware image as symbols gble_voice_bin_pcmData / gble_voice_bin_pcmSize.

Parameters:

  • symbol: symbol name
  • file: binary file name relative to the source file

Return value: none

Complete Code

The full main.c of the ble_hid_cli_sender example, identical to the official SDK, collapsed by default — click to expand:

📜 Click to expand ble_hid_cli_sender/main.c full code
c
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "bflb_mtd.h"
#include "bflb_mtimer.h"
#include "board.h"
#include "easyflash.h"
#include "incbin.h"
#include "rfparam_adapter.h"

#include "FreeRTOS.h"
#include "task.h"

#include "usb_ecm_ble.h"

#ifdef CONFIG_SHELL
#include "shell.h"
#endif

#define DBG_TAG "BLE_CLI"
#include "log.h"

#define BLE_CLI_TX_TEXT_MAX     256
#define BLE_CLI_RX_PREVIEW      48
#define BLE_VOICE_RATE_HZ       16000U
#define BLE_VOICE_SAMPLE_BYTES  2U
/* 15 ms / 480 B better matches the observed BLE pacing on this link. */
#define BLE_VOICE_FRAME_MS      15U
#define BLE_VOICE_PACKET_BYTES  ((BLE_VOICE_RATE_HZ * BLE_VOICE_SAMPLE_BYTES * BLE_VOICE_FRAME_MS) / 1000U)

INCBIN(ble_voice_bin_pcm, "audio.pcm");

static void ble_cli_dump_text(const uint8_t *data, size_t len)
{
    size_t preview = len;

    if (preview > BLE_CLI_RX_PREVIEW) {
        preview = BLE_CLI_RX_PREVIEW;
    }

    printf("\"");
    for (size_t i = 0; i < preview; i++) {
        uint8_t ch = data[i];

        if (isprint(ch)) {
            putchar((int)ch);
        } else {
            putchar('.');
        }
    }

    if (preview < len) {
        printf("...");
    }
    printf("\"");
}

static const char *ble_cli_channel_name(usb_ecm_ble_channel_t channel)
{
    switch (channel) {
        case USB_ECM_BLE_CHANNEL_VOICE:
            return "voice";
        case USB_ECM_BLE_CHANNEL_KEY:
            return "key";
        case USB_ECM_BLE_CHANNEL_USER:
            return "user";
        default:
            return "unknown";
    }
}

static int ble_cli_join_args(int argc, char **argv, int start, char *buf, size_t buf_size)
{
    size_t used = 0;

    if (!buf || buf_size == 0 || start >= argc) {
        return -1;
    }

    buf[0] = '\0';

    for (int i = start; i < argc; i++) {
        size_t part_len = strlen(argv[i]);

        if (used + part_len + (used ? 1u : 0u) + 1u > buf_size) {
            return -1;
        }

        if (used) {
            buf[used++] = ' ';
        }

        memcpy(&buf[used], argv[i], part_len);
        used += part_len;
        buf[used] = '\0';
    }

    return (int)used;
}

static int ble_cli_send_text(usb_ecm_ble_channel_t channel, int argc, char **argv, int start)
{
    char text[BLE_CLI_TX_TEXT_MAX];
    int len;
    int ret;

    len = ble_cli_join_args(argc, argv, start, text, sizeof(text));
    if (len <= 0) {
        printf("text is empty or too long (max %u)\r\n", (unsigned int)(sizeof(text) - 1u));
        return -1;
    }

    ret = usb_ecm_ble_send_channel(channel, (const uint8_t *)text, (size_t)len);
    printf("send %s %s: ", ble_cli_channel_name(channel), ret == 0 ? "ok" : "failed");
    ble_cli_dump_text((const uint8_t *)text, (size_t)len);
    printf("\r\n");
    return ret;
}

static void ble_cli_log_voice_preview(const uint8_t *buf, size_t len)
{
    const int16_t *samples = (const int16_t *)buf;

    if (!buf || len < (sizeof(int16_t) * 8U) || (len % sizeof(int16_t)) != 0U) {
        return;
    }

    LOG_I("voice tx preview samples=%d,%d,%d,%d,%d,%d,%d,%d len=%u\r\n",
          samples[0], samples[1], samples[2], samples[3],
          samples[4], samples[5], samples[6], samples[7],
          (unsigned int)len);
}

static int ble_cli_stream_voice_bin(void)
{
    uint8_t packet[BLE_VOICE_PACKET_BYTES];
    const uint8_t *bin_src = gble_voice_bin_pcmData;
    size_t bin_total = (size_t)gble_voice_bin_pcmSize;
    size_t remaining_bytes;
    unsigned long frames = 0;
    unsigned long sent_packets = 0;
    unsigned long failed_packets = 0;
    unsigned long sent_bytes = 0;
    size_t bin_offset = 0U;
    TickType_t last_log_tick;
    TickType_t next_frame_tick;
    uint32_t start_ms;
    uint32_t elapsed_ms;
    uint32_t pcm_duration_ms;

    if (bin_src == NULL || bin_total == 0U) {
        printf("ble_voice_bin no embedded pcm data\r\n");
        return -1;
    }

    if ((bin_total % BLE_VOICE_SAMPLE_BYTES) != 0U) {
        printf("ble_voice_bin embedded pcm size must be %u-byte aligned\r\n",
               (unsigned int)BLE_VOICE_SAMPLE_BYTES);
        return -1;
    }

    remaining_bytes = bin_total;
    pcm_duration_ms = (uint32_t)(((uint64_t)bin_total * 1000ULL) /
                                 (BLE_VOICE_RATE_HZ * BLE_VOICE_SAMPLE_BYTES));

    memset(packet, 0, sizeof(packet));
    memcpy(packet, bin_src,
           (remaining_bytes < sizeof(packet)) ? remaining_bytes : sizeof(packet));
    ble_cli_log_voice_preview(packet,
                              (remaining_bytes < sizeof(packet)) ? remaining_bytes : sizeof(packet));

    start_ms = bflb_mtimer_get_time_ms();
    last_log_tick = xTaskGetTickCount();
    next_frame_tick = last_log_tick;
    LOG_I("voice bin tx start embedded_bytes=%u duration_ms=%u bytes_per_frame=%u rate=%u ch=1\r\n",
          (unsigned int)bin_total,
          (unsigned int)pcm_duration_ms,
          (unsigned int)sizeof(packet),
          (unsigned int)BLE_VOICE_RATE_HZ);

    while (remaining_bytes > 0U) {
        size_t packet_len = (remaining_bytes < sizeof(packet)) ? remaining_bytes : sizeof(packet);
        TickType_t now_tick;
        int ret;

        if (frames > 0U) {
            vTaskDelayUntil(&next_frame_tick, pdMS_TO_TICKS(BLE_VOICE_FRAME_MS));
        }

        memset(packet, 0, sizeof(packet));
        memcpy(packet, bin_src + bin_offset, packet_len);

        /* usb_ecm_ble_send_channel() now applies buffer-level flow control with
         * a short per-fragment timeout, so a frame that cannot be queued in
         * time is dropped (live audio tolerates loss) rather than busy-retried. */
        ret = usb_ecm_ble_send_channel(USB_ECM_BLE_CHANNEL_VOICE, packet, packet_len);
        if (ret != 0) {
            failed_packets++;
        }

        frames++;
        bin_offset += (uint32_t)packet_len;
        remaining_bytes -= packet_len;
        sent_packets++;
        sent_bytes += (unsigned long)packet_len;

        now_tick = xTaskGetTickCount();
        if ((now_tick - last_log_tick) >= pdMS_TO_TICKS(1000)) {
            elapsed_ms = bflb_mtimer_get_time_ms() - start_ms;
            LOG_I("voice bin tx stats sent_pkt=%lu sent_bytes=%lu failed_pkt=%lu frame=%lu remaining=%u elapsed_ms=%u\r\n",
                  sent_packets, sent_bytes, failed_packets, frames,
                  (unsigned int)remaining_bytes, (unsigned int)elapsed_ms);
            last_log_tick = now_tick;
        }
    }

    elapsed_ms = bflb_mtimer_get_time_ms() - start_ms;
    printf("ble_voice_bin done frames=%lu sent_pkt=%lu sent_bytes=%lu failed_pkt=%lu embedded_bytes=%u duration_ms=%u elapsed_ms=%u\r\n",
           frames, sent_packets, sent_bytes, failed_packets,
           (unsigned int)bin_total,
           (unsigned int)pcm_duration_ms,
           (unsigned int)elapsed_ms);
    return (failed_packets == 0U) ? 0 : -1;
}

static void ble_cli_channel_rx(usb_ecm_ble_channel_t channel, const uint8_t *data, size_t len)
{
    printf("[BLE_CLI] rx %s len=%u text=", ble_cli_channel_name(channel), (unsigned int)len);
    ble_cli_dump_text(data, len);
    printf("\r\n");
}

#ifdef CONFIG_SHELL
static int ble_hid_cmd(int argc, char **argv)
{
    if (argc < 2) {
        printf("usage: ble_hid <text...>\r\n");
        return 0;
    }

    return ble_cli_send_text(USB_ECM_BLE_CHANNEL_KEY, argc, argv, 1);
}
SHELL_CMD_EXPORT_ALIAS(ble_hid_cmd, ble_hid, send KEY channel text for HID typing);

static int ble_voice_bin_cmd(int argc, char **argv)
{
    if (argc != 1) {
        printf("usage: ble_voice_bin\r\n");
        return 0;
    }

    return ble_cli_stream_voice_bin();
}
SHELL_CMD_EXPORT_ALIAS(ble_voice_bin_cmd, ble_voice_bin,
                       send full 16k mono embedded PCM from INCBIN image);
#endif

static void start_main(void *param)
{
    (void)param;

#ifdef CONFIG_SHELL
    struct bflb_device_s *uart0 = bflb_device_get_by_name("uart0");
    shell_init_with_task(uart0);
    LOG_I("Shell ready\r\n");
#endif

    usb_ecm_ble_set_channel_data_callback(USB_ECM_BLE_CHANNEL_VOICE, ble_cli_channel_rx);
    usb_ecm_ble_set_channel_data_callback(USB_ECM_BLE_CHANNEL_KEY, ble_cli_channel_rx);
    usb_ecm_ble_set_channel_data_callback(USB_ECM_BLE_CHANNEL_USER, ble_cli_channel_rx);

    LOG_I("Start BLE CLI sender\r\n");
    LOG_I("Peer name: USB-ECM-AUD\r\n");
    usb_ecm_ble_stack_init();

    vTaskDelete(NULL);
}

int main(void)
{
    board_init();

    LOG_I("BLE HID CLI Sender\r\n");

    bflb_mtd_init();
    easyflash_init();

    if (rfparam_init(0, NULL, 0) != 0) {
        LOG_E("PHY RF init failed\r\n");
        return 0;
    }

    xTaskCreate(start_main, (char *)"start_task", 2048, NULL, configMAX_PRIORITIES - 1, NULL);

    LOG_I("[OS] start scheduler\r\n");
    vTaskStartScheduler();

    while (1) {
        bflb_mtimer_delay_ms(1000);
    }
}

FAQ

Build errors or bl616 not supported

This example only supports the BLE-only BL616CL chip — build with make CHIP=bl616cl BOARD=bl616cldk. Regular Ai-M61/Ai-M62 (BL618/BL616) do not have the BLE-only variant and cannot build/run this solution.

Cannot connect to USB-ECM-AUD

Make sure the peer board is running usb_ecm_audio-notify and advertising as USB-ECM-AUD; keep the two boards close. BLE scanning takes time — wait a few seconds.

How to change the voice content

Convert any audio to 16 kHz mono s16le PCM with ffmpeg, name it audio.pcm, place it next to main.c and rebuild/reflash: ffmpeg -i input.wav -f s16le -acodec pcm_s16le -ac 1 -ar 16000 output.pcm, then rename output.pcm to audio.pcm. No separate flash address burn is needed.

ble_voice_bin says no embedded pcm data

Confirm audio.pcm exists next to main.c before building; the file size must be 2-byte aligned (16-bit samples) and the content must be s16le.

Have questions?

For any other questions, visit the unified Q&A and discussion board: Ai-Thinker Discussions

Released under the MIT License. Build Time 2026-09-11 14:52:23