Dongl Adapter
Bluetooth that behaves the same on every computer, from a USB adapter made for SimpleBLE.
A Dongl is a USB Bluetooth adapter made for SimpleBLE. Plug one in and your application no longer depends on the computer's Bluetooth: the stack runs on the Dongl, in firmware we develop together with the library, and it behaves the same on every Windows PC and Mac.
The design is deliberately simple. SimpleBLE talks to the Dongl over USB through a small protocol of its own, and with fewer parts between your code and the radio, there is less that can go wrong.
Just received a Dongl?
The Dongl QuickStart installs SimplePyBLE, runs a first scan, and points you to the examples to try next.
Why use a Dongl
- Windows and macOS give an application one built-in radio. Each Dongl is another adapter, and you can plug in several.
- On macOS, CoreBluetooth hides the device's MAC address behind an identifier that only that Mac recognizes (see Platform Notes). A Dongl reports the real address and address type on macOS and Windows alike, and a Mac test station can match devices by the address printed on their label.
- Test stations running the same firmware release behave the same, regardless of the computer or OS version.
- A Dongl needs only a USB port. It works on computers without Bluetooth hardware and with the built-in Bluetooth turned off.
- Pairing happens without the operating system, and no system dialog appears; your application answers passkey and numeric-comparison requests itself. Bonds live on the Dongl. They survive power cycles, travel with it to another computer, and can be removed from code. See Pairing and bonding.
What you need
| Component | Requirement |
|---|---|
| Firmware | N-Series v0.1.0 |
| Host | Windows, macOS, or Linux, over USB (on Linux, see Dongl permissions) |
| Languages | C++ (SimpleBLE), C (SimpleCBLE), Python (SimplePyBLE), and C# (SimpleSharpBLE), version 1.2.0 or later; Go (SimpleGoBLE), Java (SimpleJavaBLE), Rust (SimpleRsBLE), JavaScript (SimpleJSBLE), and Kotlin Multiplatform (SimpleKtBLE), version 1.2.1 or later |
The firmware acts as a BLE central. It supports scanning with scan responses and service data, connections, reads and writes, notifications and indications, descriptors, MTU reporting, and pairing with bonding. Reads return values of up to 512 bytes.
Select a Dongl
From SimpleBLE 1.2.1, the Dongl backend is on by default, so plugged-in Dongls appear next to the computer's own Bluetooth adapter. To use only Dongls, take the adapters of the Dongl backend, as below. To leave Dongls out, turn the backend off before any other SimpleBLE call. With 1.2.0, turn it on first. Each Dongl identifies itself as SimpleBLE Dongl N-Series, and address() returns its own Bluetooth address. Listing adapters doesn't open any Dongl: each one opens on its first operation, such as a scan, and stays open while your program holds its adapter.
#include <simpleble/Backend.h>
#include <simpleble/SimpleBLE.h>
std::vector<SimpleBLE::Adapter> dongls = SimpleBLE::Backend::get_dongl().adapters();
if (dongls.empty()) {
std::cerr << "No Dongl found." << std::endl;
return EXIT_FAILURE;
}
SimpleBLE::Adapter adapter = dongls[0];
std::cout << adapter.identifier() << " [" << adapter.address() << "]" << std::endl;simpleble_error_t* error = NULL;
simpleble_adapter_t adapter = NULL;
simpleble_backend_t dongl = simpleble_backend_get_dongl(&error);
if (dongl && simpleble_backend_get_adapters_count(dongl, &error) > 0) {
adapter = simpleble_backend_get_adapters_handle(dongl, 0, &error);
}
simpleble_backend_release_handle(dongl);
if (error) fprintf(stderr, "%s\n", simpleble_error_message(error));
simpleble_error_release(&error);
// Use the adapter, then release it with simpleble_adapter_release_handle(adapter).import simplepyble
adapters = simplepyble.Backend.get_dongl().adapters()
if not adapters:
raise SystemExit("No Dongl found.")
adapter = adapters[0]
print(adapter.identifier(), adapter.address())using SimpleSharpBLE;
IReadOnlyList<Adapter> adapters;
using (var dongl = Backend.GetDongl()) adapters = dongl.Adapters;
if (adapters.Count == 0)
{
Console.WriteLine("No Dongl found.");
return;
}
var adapter = adapters[0];
Console.WriteLine($"{adapter.Identifier} [{adapter.Address}]");import ble "github.com/simpleble/simplegoble"
dongl, err := ble.GetDonglBackend()
if err != nil {
return err
}
defer dongl.Close()
adapters, err := dongl.Adapters() // Close each adapter when finished.
if err != nil {
return err
}
if len(adapters) == 0 {
return errors.New("no Dongl found")
}
adapter := adapters[0]
name, _ := adapter.Identifier()
address, _ := adapter.Address()
fmt.Printf("%s [%s]\n", name, address)import org.simplejavable.Adapter;
import org.simplejavable.Backend;
List<Adapter> adapters = Backend.getDongl().getAdapters();
if (adapters.isEmpty()) {
System.err.println("No Dongl found.");
return;
}
Adapter adapter = adapters.get(0);
System.out.println(adapter.getIdentifier() + " [" + adapter.getAddress() + "]");use simplersble::Backend;
let adapters = Backend::get_dongl().unwrap().adapters().unwrap();
if adapters.is_empty() {
println!("No Dongl found.");
return;
}
let adapter = &adapters[0];
println!("{} [{}]", adapter.identifier().unwrap(), adapter.address().unwrap());import { Backend } from 'simplejsble';
const [adapter] = await (await Backend.getDongl()).adapters();
console.log(`${adapter.identifier()} [${adapter.address()}]`);import org.simplektble.Backend
val adapter = Backend.getDongl().getAdapters().first()
println("${adapter.identifier} [${adapter.address}]")A Dongl adapter works like any other SimpleBLE adapter, and the recipes apply unchanged.
Use several Dongls
Each Dongl is a separate adapter with its own radio. Plug in as many as your setup needs, for example one per test fixture, and drive each from its own thread. address() identifies a unit across restarts and USB ports. Using several Dongls from C or C# requires SimpleBLE 1.2.1 or later.
The programs below check three devices in parallel, one per Dongl.
#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <simpleble/Backend.h>
#include <simpleble/SimpleBLE.h>
int main() {
const std::string service = "0000180a-0000-1000-8000-00805f9b34fb"; // Device Information
const std::string characteristic = "00002a26-0000-1000-8000-00805f9b34fb"; // Firmware Revision String
const std::vector<std::string> targets = {"C4:2B:91:0E:5A:17", "F0:9E:3C:44:81:0B", "D2:41:6A:9C:03:5E"};
std::vector<SimpleBLE::Adapter> dongls = SimpleBLE::Backend::get_dongl().adapters();
if (dongls.size() < targets.size()) {
std::cerr << "Found " << dongls.size() << " Dongls, need " << targets.size() << "." << std::endl;
return EXIT_FAILURE;
}
std::vector<std::string> results(targets.size(), "not found");
std::vector<std::thread> workers;
for (size_t i = 0; i < targets.size(); i++) {
workers.emplace_back([&, i, adapter = dongls[i]]() mutable {
try {
adapter.scan_for(5000);
for (auto& peripheral : adapter.scan_get_results()) {
if (peripheral.address() != targets[i]) continue;
peripheral.connect();
results[i] = std::string(peripheral.read(service, characteristic));
peripheral.disconnect();
break;
}
} catch (const std::exception& e) {
results[i] = e.what();
}
});
}
for (auto& worker : workers) {
worker.join();
}
for (size_t i = 0; i < targets.size(); i++) {
std::cout << targets[i] << " via " << dongls[i].address() << ": " << results[i] << std::endl;
}
return EXIT_SUCCESS;
}#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <simplecble/simplecble.h>
#define TARGET_COUNT 3
static const char* targets[TARGET_COUNT] = {"C4:2B:91:0E:5A:17", "F0:9E:3C:44:81:0B", "D2:41:6A:9C:03:5E"};
typedef struct {
simpleble_adapter_t adapter;
const char* target;
char result[64];
} job_t;
static void* check(void* arg) {
job_t* job = arg;
simpleble_uuid_t service = {"0000180a-0000-1000-8000-00805f9b34fb"}; // Device Information
simpleble_uuid_t characteristic = {"00002a26-0000-1000-8000-00805f9b34fb"}; // Firmware Revision String
simpleble_error_t* error = NULL;
snprintf(job->result, sizeof(job->result), "not found");
simpleble_adapter_scan_for(job->adapter, 5000, &error);
size_t count = error ? 0 : simpleble_adapter_scan_get_results_count(job->adapter, &error);
for (size_t i = 0; i < count && !error; i++) {
simpleble_peripheral_t peripheral = simpleble_adapter_scan_get_results_handle(job->adapter, i, &error);
if (error) break;
char* address = simpleble_peripheral_address(peripheral, &error);
bool match = address && strcmp(address, job->target) == 0;
simpleble_free(address);
if (match) {
simpleble_peripheral_connect(peripheral, &error);
if (!error) {
size_t length = 0;
uint8_t* data = simpleble_peripheral_read(peripheral, service, characteristic, &length, &error);
if (!error) snprintf(job->result, sizeof(job->result), "%.*s", (int)length, data ? (char*)data : "");
simpleble_free(data);
simpleble_error_t* disconnect_error = NULL;
simpleble_peripheral_disconnect(peripheral, &disconnect_error);
simpleble_error_release(&disconnect_error);
}
}
simpleble_peripheral_release_handle(peripheral);
if (match) break;
}
if (error) snprintf(job->result, sizeof(job->result), "%s", simpleble_error_message(error));
simpleble_error_release(&error);
return NULL;
}
int main(void) {
simpleble_error_t* error = NULL;
simpleble_adapter_t dongls[TARGET_COUNT] = {NULL};
size_t dongl_count = 0;
simpleble_backend_t backend = simpleble_backend_get_dongl(&error);
if (backend) {
size_t count = simpleble_backend_get_adapters_count(backend, &error);
for (size_t i = 0; i < count && dongl_count < TARGET_COUNT && !error; i++) {
dongls[dongl_count] = simpleble_backend_get_adapters_handle(backend, i, &error);
if (!error) dongl_count++;
}
simpleble_backend_release_handle(backend);
}
if (error) fprintf(stderr, "%s\n", simpleble_error_message(error));
simpleble_error_release(&error);
int status = EXIT_FAILURE;
if (dongl_count < TARGET_COUNT) {
fprintf(stderr, "Found %zu Dongls, need %d.\n", dongl_count, TARGET_COUNT);
} else {
job_t jobs[TARGET_COUNT];
pthread_t workers[TARGET_COUNT];
for (size_t i = 0; i < TARGET_COUNT; i++) {
jobs[i] = (job_t){.adapter = dongls[i], .target = targets[i]};
pthread_create(&workers[i], NULL, check, &jobs[i]);
}
for (size_t i = 0; i < TARGET_COUNT; i++) {
pthread_join(workers[i], NULL);
}
for (size_t i = 0; i < TARGET_COUNT; i++) {
char* address = simpleble_adapter_address(dongls[i], &error);
printf("%s via %s: %s\n", targets[i], address ? address : "?", jobs[i].result);
simpleble_free(address);
}
simpleble_error_release(&error);
status = EXIT_SUCCESS;
}
for (size_t i = 0; i < dongl_count; i++) {
simpleble_adapter_release_handle(dongls[i]);
}
return status;
}import threading
import simplepyble
SERVICE = "0000180a-0000-1000-8000-00805f9b34fb" # Device Information
CHARACTERISTIC = "00002a26-0000-1000-8000-00805f9b34fb" # Firmware Revision String
TARGETS = ["C4:2B:91:0E:5A:17", "F0:9E:3C:44:81:0B", "D2:41:6A:9C:03:5E"]
adapters = simplepyble.Backend.get_dongl().adapters()
if len(adapters) < len(TARGETS):
raise SystemExit(f"Found {len(adapters)} Dongls, need {len(TARGETS)}.")
def check(adapter, address):
adapter.scan_for(5000)
peripheral = next((p for p in adapter.scan_get_results() if p.address() == address), None)
if peripheral is None:
print(f"{address}: not found by {adapter.address()}")
return
peripheral.connect()
try:
print(f"{address}: {peripheral.read(SERVICE, CHARACTERISTIC).decode()}")
finally:
peripheral.disconnect()
workers = [threading.Thread(target=check, args=pair) for pair in zip(adapters, TARGETS)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()using System.Text;
using SimpleSharpBLE;
const string Service = "0000180a-0000-1000-8000-00805f9b34fb"; // Device Information
const string Characteristic = "00002a26-0000-1000-8000-00805f9b34fb"; // Firmware Revision String
string[] targets = ["C4:2B:91:0E:5A:17", "F0:9E:3C:44:81:0B", "D2:41:6A:9C:03:5E"];
IReadOnlyList<Adapter> adapters;
using (var dongl = Backend.GetDongl()) adapters = dongl.Adapters;
try
{
if (adapters.Count < targets.Length)
{
Console.WriteLine($"Found {adapters.Count} Dongls, need {targets.Length}.");
return;
}
string[] results = await Task.WhenAll(targets.Select((address, i) => Check(adapters[i], address)));
for (int i = 0; i < targets.Length; i++)
{
Console.WriteLine($"{targets[i]} via {adapters[i].Address}: {results[i]}");
}
}
finally
{
foreach (var adapter in adapters) adapter.Dispose();
}
async Task<string> Check(Adapter adapter, string address)
{
try
{
await adapter.ScanForAsync(TimeSpan.FromSeconds(5));
var peripherals = adapter.ScanGetResults();
try
{
var peripheral = peripherals.FirstOrDefault(p => p.Address == address);
if (peripheral is null) return "not found";
await peripheral.ConnectAsync();
try
{
return Encoding.UTF8.GetString(await peripheral.ReadAsync(Service, Characteristic));
}
finally
{
await peripheral.DisconnectAsync();
}
}
finally
{
foreach (var p in peripherals) p.Dispose();
}
}
catch (Exception e)
{
return e.Message;
}
}import { Backend } from 'simplejsble';
const SERVICE = '0000180a-0000-1000-8000-00805f9b34fb'; // Device Information
const CHARACTERISTIC = '00002a26-0000-1000-8000-00805f9b34fb'; // Firmware Revision String
const TARGETS = ['C4:2B:91:0E:5A:17', 'F0:9E:3C:44:81:0B', 'D2:41:6A:9C:03:5E'];
const adapters = await (await Backend.getDongl()).adapters();
async function check(adapter, address) {
await adapter.scanFor(5000);
const peripheral = adapter.scanGetResults().find(p => p.address() === address);
if (!peripheral) {
console.log(`${address}: not found by ${adapter.address()}`);
return;
}
await peripheral.connect();
try {
console.log(`${address}: ${await peripheral.read(SERVICE, CHARACTERISTIC)}`);
} finally {
await peripheral.disconnect();
}
}
// Calls on different adapters run in parallel.
await Promise.all(TARGETS.map((address, i) => check(adapters[i], address)));import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.simplektble.Adapter
import org.simplektble.Backend
import org.simplektble.BluetoothUUID
val service = BluetoothUUID("0000180a-0000-1000-8000-00805f9b34fb") // Device Information
val characteristic = BluetoothUUID("00002a26-0000-1000-8000-00805f9b34fb") // Firmware Revision String
val targets = listOf("C4:2B:91:0E:5A:17", "F0:9E:3C:44:81:0B", "D2:41:6A:9C:03:5E")
suspend fun check(adapter: Adapter, address: String): String {
adapter.scanFor(5000)
val peripheral = adapter.scanGetResults().find { it.address.value == address }
?: return "$address: not found by ${adapter.address}"
peripheral.connect()
try {
return "$address: ${peripheral.read(service, characteristic).decodeToString()}"
} finally {
peripheral.disconnect()
}
}
suspend fun main() = coroutineScope {
val adapters = Backend.getDongl().getAdapters()
// Calls on different adapters run in parallel.
targets.zip(adapters) { address, adapter -> async { check(adapter, address) } }
.awaitAll()
.forEach(::println)
}Dongl addresses are uppercase, as in C4:2B:91:0E:5A:17; write target addresses the same way.
SimplePyBLE releases the GIL during blocking calls, which lets the Python threads scan and connect in parallel. The C example uses POSIX threads; on Windows, create the workers with C11 thrd_create or _beginthreadex instead.
Pairing and bonding
Pairing starts when the device sends a security request. The Dongl then pairs with it, using LE Secure Connections when the device supports them, and stores the bond. If the user needs to enter or confirm a number, the Dongl asks your application through a callback. When a bonded device reconnects, the Dongl encrypts the link with the stored keys instead of pairing again.
Register the callbacks your device needs before connect(), since pairing can start as soon as the link is up:
- Passkey entry (
set_passkey_request_callback): the device shows a passkey; return the six digits the user typed, or reject. - Passkey display (
set_passkey_display_callback): show the passkey so the user can type it on the device. - Numeric comparison (
set_numeric_comparison_callback): return true only if the device shows the same six digits.
Callbacks run on a worker thread and can block while the user answers. A request with no callback registered is rejected. The bindings name them as follows.
| Language | Callbacks |
|---|---|
| C | simpleble_advanced_dongl_set_*_callback |
| C# | Advanced.Dongl.Set*Callback |
| Python | simplepyble.advanced.dongl.set_*_callback |
| Java | Advanced.Dongl.set*Callback |
| Go | Peripheral.SetCallbackOnPasskeyRequest, SetCallbackOnPasskeyDisplay, SetCallbackOnNumericComparison |
| Rust | simplersble::advanced::dongl::set_*_callback |
| Node.js | Peripheral.setPasskeyRequestHandler, setPasskeyDisplayHandler, setNumericComparisonHandler |
| Kotlin Multiplatform | Peripheral.setPasskeyRequestHandler, setPasskeyDisplayHandler, setNumericComparisonHandler |
#include <simpleble/Advanced.h>
SimpleBLE::Advanced::Dongl::set_numeric_comparison_callback(peripheral, [](const std::string& passkey) {
std::cout << "Does the device show " << passkey << "? [y/N] " << std::flush;
std::string answer;
std::getline(std::cin, answer);
return answer == "y";
});
peripheral.connect();static bool confirm_passkey(simpleble_peripheral_t handle, const char* passkey, void* userdata) {
printf("Does the device show %s? [y/N] ", passkey);
fflush(stdout);
return getchar() == 'y';
}
simpleble_advanced_dongl_set_numeric_comparison_callback(peripheral, confirm_passkey, NULL, &error);
if (!error) simpleble_peripheral_connect(peripheral, &error);import simplepyble
def confirm(passkey):
answer = input(f"Does the device show {passkey}? [y/N] ")
return answer == "y"
simplepyble.advanced.dongl.set_numeric_comparison_callback(peripheral, confirm)
peripheral.connect()Advanced.Dongl.SetNumericComparisonCallback(peripheral, passkey =>
{
Console.Write($"Does the device show {passkey}? [y/N] ");
return Console.ReadLine()?.Trim() == "y";
});
peripheral.Connect();err := peripheral.SetCallbackOnNumericComparison(func(passkey string) bool {
fmt.Printf("Does the device show %s? [y/N] ", passkey)
answer, _ := bufio.NewReader(os.Stdin).ReadString('\n')
return strings.TrimSpace(answer) == "y"
})
if err != nil {
return err
}
if err := peripheral.Connect(); err != nil {
return err
}Advanced.Dongl.setNumericComparisonCallback(peripheral, passkey -> {
System.out.print("Does the device show " + passkey + "? [y/N] ");
return new Scanner(System.in).nextLine().trim().equals("y");
});
peripheral.connect();use std::io::Write;
use simplersble::advanced;
advanced::dongl::set_numeric_comparison_callback(&peripheral, |passkey| {
print!("Does the device show {}? [y/N] ", passkey);
std::io::stdout().flush().unwrap();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).unwrap();
answer.trim() == "y"
})
.unwrap();
peripheral.connect().unwrap();import { createInterface } from 'node:readline/promises';
const input = createInterface({ input: process.stdin, output: process.stdout });
await peripheral.setNumericComparisonHandler(async passkey => {
return (await input.question(`Does the device show ${passkey}? [y/N] `)) === 'y';
});
await peripheral.connect();peripheral.setNumericComparisonHandler { passkey ->
print("Does the device show $passkey? [y/N] ")
readln().trim() == "y"
}
peripheral.connect()Bonds are stored on the Dongl, not on the computer:
adapter.get_paired_peripherals()lists the devices bonded to that Dongl.peripheral.is_paired()checks one device.peripheral.unpair()deletes its bond from the Dongl.
Update the firmware
Dongls ship with the firmware installed. To update one, download the firmware package from the Dongl page, switch the Dongl into its bootloader, and program app_dfu.zip with Nordic's nrfutil:
python3 -m pip install pyusb
python3 trigger_bootloader.py
nrfutil nrf5sdk-tools dfu usb-serial -pkg app_dfu.zip -p <serial-port>With several Dongls connected, python3 trigger_bootloader.py --list shows them and --serial picks one. The package README describes the other update packages.
For evaluation, the firmware also runs on Nordic's nRF52840 Dongle and DK: program mbr_sd_bootloader_app.hex from the package over SWD.
Licensing
The N-Series firmware is free for non-commercial prototyping, testing, and validation. Commercial use needs a license. Dongl hardware includes a commercial license to use SimpleBLE through the Dongl backend with that unit. SimpleBLE itself is source-available under the Business Source License 1.1; see Licensing.
