Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

BLE Transport

The Shiny.Obd.Ble package provides a Bluetooth LE transport for communicating with ELM327-compatible OBD adapters using Shiny.BluetoothLE.

<PackageReference Include="Shiny.Obd" />
<PackageReference Include="Shiny.Obd.Ble" />

OBD BLE adapters expose a GATT service with read (notify) and write characteristics. The UUIDs vary by manufacturer — defaults are set for common ELM327 BLE clones.

var config = new BleObdConfiguration
{
// GATT UUIDs — defaults work for most ELM327 BLE clones
ServiceUuid = "FFF0",
ReadCharacteristicUuid = "FFF1", // notifications (RX from adapter)
WriteCharacteristicUuid = "FFF2", // write commands (TX to adapter)
// Optional: filter scan results by device name
DeviceNameFilter = "OBDLink",
// Timeout for a single command response
CommandTimeout = TimeSpan.FromSeconds(10),
// How long to wait for the BLE link itself, before any OBD initialization
ConnectTimeout = TimeSpan.FromSeconds(30),
// Hand the platform a standing reconnect for this adapter. Off by default — see below.
AutoConnect = false
};

AutoConnect is off by default, and that default matters more than it looks.

On Android it selects ConnectGatt(autoConnect: true), the background connection path: the controller only attempts during widely spaced scan windows, so an in-range adapter a direct connect reaches in a few hundred milliseconds takes tens of seconds this way. It also arms the platform’s own reconnect, which races any caller that supervises the session itself — each side’s teardown cancels the other’s attempt in flight.

Leave it off for an adapter somebody is actively waiting on, which is the normal case for OBD. Turn it on only when you want the platform to re-establish a peripheral whenever it happens to reappear and nothing in your app is doing that job.

The transport reads the write characteristic’s own properties on connect and writes with a GATT response unless the adapter advertises write-without-response. Write-without-response is preferred where it is offered — the ELM327 exchange is request/response over a serial emulation, so the notification is already the acknowledgement — but it has to be offered: a clone whose TX characteristic is write-with-response only silently drops the write, nothing ever answers, and the caller sits out the full CommandTimeout for a reply that was never coming.

Adapter Service Read (RX) Write (TX)
ELM327 BLE clones FFF0 FFF1 FFF2
OBDLink MX+ Varies Varies Varies
Vgate iCar Pro Varies Varies Varies

Use BleObdDeviceScanner to find adapters, then pass the selected device:

IBleManager bleManager = /* from DI or Shiny setup */;
var scanner = new BleObdDeviceScanner(bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBDII" // optional: matches any device name containing "OBDII"
});
var cts = new CancellationTokenSource();
var devices = new List<ObdDiscoveredDevice>();
await scanner.Scan(device =>
{
devices.Add(device);
Console.WriteLine($"Found: {device.Name} ({device.Id})");
}, cts.Token);
// Connect to a selected device
var transport = new BleObdTransport(devices[0], new BleObdConfiguration());
var connection = new ObdConnection(transport);
await connection.Connect();

BleObdDeviceScanner deduplicates by peripheral UUID — each device is reported once. Cancel the token to stop scanning.

Let the transport scan for the first device matching your filter:

IBleManager bleManager = /* from DI or Shiny setup */;
var transport = new BleObdTransport(bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBDII" // matches any device name containing "OBDII"
});
var connection = new ObdConnection(transport);
await connection.Connect(); // scans, connects, initializes

When DeviceNameFilter is null, the transport connects to the first BLE device found during scanning.

If you’ve already discovered the peripheral (e.g. from a scan UI), pass it directly:

IPeripheral peripheral = /* from your BLE scan */;
var transport = new BleObdTransport(peripheral, new BleObdConfiguration());
var connection = new ObdConnection(transport);
await connection.Connect(); // connects to known peripheral, initializes

AddShinyObdBluetoothLE() works on every platform Shiny.BluetoothLE supports — iOS, Android, Mac Catalyst, macOS, Windows, Linux (BlueZ) and Blazor WebAssembly (Web Bluetooth).

It registers BleObdConfiguration, IObdDeviceScanner, IObdTransport and IObdConnection as singletons — an OBD adapter is a single physical resource, and a scoped registration would leave two consumers fighting over one link. Everything uses TryAdd.

The BLE manager is registered for you:

using Shiny;
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddShinyObdBluetoothLE(new BleObdConfiguration
{
DeviceNameFilter = "OBD"
});

Linux, Blazor WebAssembly, Windows and Apple desktop

Section titled “Linux, Blazor WebAssembly, Windows and Apple desktop”

Add your platform package’s AddBluetoothLE() call as well. Order does not matter — DI resolves lazily, so either call may come first.

// Linux (Shiny.BluetoothLE.Linux) — BlueZ over D-Bus
services.AddBluetoothLE();
services.AddShinyObdBluetoothLE(new BleObdConfiguration { DeviceNameFilter = "OBDCheck" });
// Blazor WebAssembly (Shiny.BluetoothLE.Blazor) — Web Bluetooth
builder.Services.AddBluetoothLE();
builder.Services.AddShinyObdBluetoothLE();
using Shiny.Obd;
using Shiny.Obd.Ble;
using Shiny.Obd.Commands;
using Shiny.BluetoothLE;
public class ObdService
{
readonly IBleManager bleManager;
IObdConnection? connection;
public ObdService(IBleManager bleManager)
{
this.bleManager = bleManager;
}
public async Task Connect(CancellationToken ct = default)
{
var transport = new BleObdTransport(this.bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBDLink"
});
this.connection = new ObdConnection(transport);
await this.connection.Connect(ct);
}
public async Task<DashboardData> ReadDashboard(CancellationToken ct = default)
{
if (this.connection == null || !this.connection.IsConnected)
throw new InvalidOperationException("Not connected");
return new DashboardData
{
Speed = await this.connection.Execute(StandardCommands.VehicleSpeed, ct),
Rpm = await this.connection.Execute(StandardCommands.EngineRpm, ct),
CoolantTemp = await this.connection.Execute(StandardCommands.CoolantTemperature, ct),
FuelLevel = await this.connection.Execute(StandardCommands.FuelLevel, ct),
ThrottlePosition = await this.connection.Execute(StandardCommands.ThrottlePosition, ct)
};
}
public async Task Disconnect()
{
if (this.connection != null)
{
await this.connection.Disconnect();
await this.connection.DisposeAsync();
this.connection = null;
}
}
}
public class DashboardData
{
public int Speed { get; set; }
public int Rpm { get; set; }
public int CoolantTemp { get; set; }
public double FuelLevel { get; set; }
public double ThrottlePosition { get; set; }
}

The BLE transport:

  1. Scans for a peripheral matching the optional device name filter (or uses a pre-provided peripheral / discovered device). The scan itself is deliberately unfiltered — see Why the scan isn’t filtered by service UUID
  2. Connects using Shiny’s task-based ConnectAsync
  3. Subscribes to notifications on the read characteristic via NotifyCharacteristic
  4. Sends commands by writing bytes to the write characteristic via WriteCharacteristicAsync
  5. Collects response bytes from notifications into a buffer until the ELM327 > prompt is received
  6. Returns the complete response string

Commands are serialized with a semaphore — only one command executes at a time, which matches the ELM327’s single-threaded request-response protocol.

Both the scanner and the transport’s auto-scan match on the peripheral’s name if it has one, and fall back to the local name in the advertisement payload otherwise.

That fallback matters on iOS. CBPeripheral.Name is null while scanning a peripheral the device has never connected to — the name lives only in the advertisement — so matching on the peripheral name alone finds almost nothing on iPhone. Android and Windows populate the name from the scan record, which is why this only shows up on Apple platforms.

The fallback makes the name better, not reliable. Plenty of ELM327 clones advertise no name of any kind, and on iOS an adapter you have never connected to may have neither source until a connection has succeeded and CoreBluetooth has cached one.

BleObdDeviceScanner therefore surfaces these too, with Name as an empty string. Identify them by Id — the BLE peripheral UUID, which is always present — and let your picker fall back to the id or RSSI for the row label.

This is why you should persist Id, never Name, when remembering an adapter to reconnect to:

// Pairing: remember the id
settings.AdapterId = device.Id;
// Later, reconnecting
await scanner.Scan(device =>
{
if (device.Id == settings.AdapterId)
selected = device;
}, cts.Token);

Requiring a name here is a subtle trap on iOS, because it only bites the first connection of a process — the symptom is that pairing works, the first reconnect after a cold start fails, and every reconnect after that succeeds.

Setting DeviceNameFilter still excludes unnamed adapters, and that is correct: a filter cannot match a name that isn’t there. Use one when narrowing by name is what you actually want.

Why the scan isn’t filtered by service UUID

Section titled “Why the scan isn’t filtered by service UUID”

ServiceUuid is used to talk to the adapter after connecting. It is deliberately not used as a scan filter.

CoreBluetooth matches a scan filter against the advertisement only, and most ELM327 clones advertise nothing but a local name — their GATT service is discoverable only once you connect. Filtering the scan on FFF0 would therefore find nothing at all on iOS.

BleObdDeviceScanner logs every advertisement it sees at Debug level, before any filtering is applied:

BLE advertisement - Name: VEEPEAK, Peripheral.Name: (null), Id: 9E4A..., RSSI: -63, Services: (none advertised)

Enable debug logging to see it:

builder.Logging.AddDebug().SetMinimumLevel(LogLevel.Debug);

Read the line as follows:

  • The adapter isn’t in the log at all — it isn’t advertising, is out of range, or BLE permissions were denied.
  • It’s in the log but not in your callback — your DeviceNameFilter doesn’t match the Name shown. Note that the filter is a case-insensitive substring match.
  • Name: (none) — the adapter advertises no name at all, so no name filter can match it. It is still surfaced to your callback with an empty Name as long as DeviceNameFilter is null; select it by Id.
  • Services: (none advertised) — normal for ELM327 clones, and harmless. The service is found after connecting.

The adapter’s GATT UUIDs don’t match your configuration. Services: in the log above only lists what was advertised, which usually isn’t the full picture — connect with a BLE scanner app (like nRF Connect) and read the real service and characteristic UUIDs off the device, then set them on BleObdConfiguration.