Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration!How!?

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)
};
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

Register BLE and the scanner in your MauiProgram.cs:

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

AddShinyObdBluetoothLE registers BleObdConfiguration and IObdDeviceScanner (BleObdDeviceScanner).

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.

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. Leave DeviceNameFilter null and select the device by Id instead.
  • 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.