Install
| Product | Versions |
|---|---|
| .NET |
README
Shiny.Obd
A .NET library for communicating with vehicles through OBD-II (On-Board Diagnostics) adapters. Supports ELM327 and OBDLink (STN) adapters over pluggable transports, starting with Bluetooth LE.
Features
- Command-object pattern β OBD commands are objects, not methods. Pass built-in commands or create your own for custom PIDs.
- Generic return types β each command declares its return type (
int,double,string,TimeSpan, etc.) with compile-time safety. - Pluggable transports β
IObdTransportabstracts the communication channel. Ship with BLE; add WiFi or USB later. - Adapter auto-detection β detects ELM327 vs OBDLink (STN) adapters via ATI and runs the appropriate initialization sequence.
- Adapter profiles β
IObdAdapterProfilelets you define custom init sequences. Built-in profiles for ELM327 and OBDLink. - Multi-frame CAN responses β the byte-count line and per-frame
N:index an ELM327 prints for a large reply (the VIN, or mode 03 with three or more codes) are treated as framing and discarded, with spaced and unspaced hex both accepted. - Task-based async β fully async/await throughout, no Reactive Extensions required in consuming code.
- 30+ standard commands included β speed, RPM, temperatures, pressures, throttle and pedal position, fuel level, trims, rate and system status, timing advance, engine load, odometer, distances and timers, VIN, calibration IDs, hybrid battery life and more.
- Supported-PID discovery β
SupportedPidsCommandreads the mode 01 bitmask blocks so you only ever query readings the vehicle in front of you actually reports. - Diagnostic trouble codes β read stored, pending and permanent codes (modes 03/07/0A) as SAE J2012 strings, and clear them (mode 04). CAN and pre-CAN response framing are both handled.
- Emissions monitor readiness β the full mode 01 PID 01/41 bit layout decoded for both spark and compression ignition, with
IsReadyForInspectionanswering the question an emissions test actually asks. - Freeze frames β
AsFreezeFrame()on any mode 01 command reads the same PID out of the snapshot the ECU stored when a code was set, so you get the conditions at the moment of the fault rather than the conditions now.
Projects
| Package | Target | Description |
|---|---|---|
Shiny.Obd |
net10.0 |
Core library β commands, connection, transport abstraction |
Shiny.Obd.Ble |
net10.0 |
BLE transport using Shiny.BluetoothLE |
Quick Start
1. Install packages
<!-- Core (always needed) -->
<PackageReference Include="Shiny.Obd" />
<!-- BLE transport -->
<PackageReference Include="Shiny.Obd.Ble" />
2. Connect and query
using Shiny.Obd;
using Shiny.Obd.Ble;
using Shiny.Obd.Commands;
// Create BLE transport (scans for adapter automatically)
var transport = new BleObdTransport(bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBDLink" // optional: filter by adapter name
});
// Create connection (auto-detects adapter type)
var connection = new ObdConnection(transport);
await connection.Connect();
// Execute commands
var speed = await connection.Execute(StandardCommands.VehicleSpeed); // int (km/h)
var rpm = await connection.Execute(StandardCommands.EngineRpm); // int
var vin = await connection.Execute(StandardCommands.Vin); // string
Console.WriteLine($"Speed: {speed} km/h, RPM: {rpm}, VIN: {vin}");
Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your App β
β await connection.Execute(StandardCommands.*) β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββ
β ObdConnection β
β β’ Adapter detection (ATI probe) β
β β’ Profile-based initialization β
β β’ ELM327 response parsing (hex β bytes) β
β β’ Error handling (NO DATA, UNABLE TO CONNECT) β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββ
β IObdTransport β
β Pluggable transport layer β
β ββββββββββββββββ ββββββββββ βββββββββββ β
β β BleObdTransportβ β WiFi β β USB β β
β β (Shiny BLE) β β(future)β β(future) β β
β ββββββββββββββββ ββββββββββ βββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Commands
Standard Commands
All standard commands are available as singletons via StandardCommands:
| Command | Mode | PID | Return Type | Unit |
|---|---|---|---|---|
VehicleSpeed |
01 | 0D | int |
km/h |
EngineRpm |
01 | 0C | int |
RPM |
CoolantTemperature |
01 | 05 | int |
Β°C |
ThrottlePosition |
01 | 11 | double |
% |
FuelLevel |
01 | 2F | double |
% |
CalculatedEngineLoad |
01 | 04 | double |
% |
IntakeAirTemperature |
01 | 0F | int |
Β°C |
RuntimeSinceStart |
01 | 1F | TimeSpan |
β |
Vin |
09 | 02 | string |
β |
Odometer |
01 | A6 | double |
km |
DistanceSinceCodesCleared |
01 | 31 | int |
km |
ControlModuleVoltage |
01 | 42 | double |
V |
MassAirFlow |
01 | 10 | double |
g/s |
EngineFuelRate |
01 | 5E | double |
L/h |
EngineOilTemperature |
01 | 5C | int |
Β°C |
FuelType |
01 | 51 | byte |
J1979 code |
HybridBatteryLife |
01 | 5B | double |
% |
MonitorStatus |
01 | 01 | MonitorStatus |
MIL, code count, readiness |
MonitorStatusThisDriveCycle |
01 | 41 | MonitorStatus |
readiness, this cycle |
FuelSystemStatus |
01 | 03 | FuelSystemStatus |
loop state |
IntakeManifoldPressure |
01 | 0B | int |
kPa |
BarometricPressure |
01 | 33 | int |
kPa |
TimingAdvance |
01 | 0E | double |
Β° BTDC |
AmbientAirTemperature |
01 | 46 | int |
Β°C |
RelativeAcceleratorPedalPosition |
01 | 5A | double |
% |
CommandedThrottleActuator |
01 | 4C | double |
% |
DistanceWithMilOn |
01 | 21 | int |
km |
TimeRunWithMilOn |
01 | 4D | TimeSpan |
minutes |
TimeSinceCodesCleared |
01 | 4E | TimeSpan |
minutes |
CalibrationId |
09 | 04 | IReadOnlyList<string> |
β |
var speed = await connection.Execute(StandardCommands.VehicleSpeed);
var rpm = await connection.Execute(StandardCommands.EngineRpm);
var coolant = await connection.Execute(StandardCommands.CoolantTemperature);
var throttle = await connection.Execute(StandardCommands.ThrottlePosition);
var fuel = await connection.Execute(StandardCommands.FuelLevel);
var load = await connection.Execute(StandardCommands.CalculatedEngineLoad);
var intakeTemp = await connection.Execute(StandardCommands.IntakeAirTemperature);
var runtime = await connection.Execute(StandardCommands.RuntimeSinceStart);
var vin = await connection.Execute(StandardCommands.Vin);
// MIL state and how many confirmed codes are stored
var status = await connection.Execute(StandardCommands.MonitorStatus);
Console.WriteLine($"Check engine: {status.MilOn}, {status.DtcCount} code(s)");
// Fuel type is a J1979 code β FuelTypes turns it into a name, or null when it is
// 0x00 ("not available") or outside the table. Read it once per connection; a vehicle
// does not change what it burns between polls.
var fuelType = FuelTypes.Describe(await connection.Execute(StandardCommands.FuelType));
Commands that need construction data are not on StandardCommands:
// Fuel trim takes a bank β 128 is zero correction, positive means the ECU is adding fuel
var shortTerm = await connection.Execute(FuelTrimCommand.ShortTermBank1()); // 0106
var longTerm = await connection.Execute(FuelTrimCommand.LongTermBank1()); // 0107
// Pedal position takes a sensor. This is the driver's *input* β unlike ThrottlePosition
// (PID 0x11), which is absolute throttle plate position and carries a 12-18% closed floor
var pedal = await connection.Execute(AcceleratorPedalPositionCommand.D()); // 0149
Fuel trims only mean anything in closed loop β in open loop the ECU runs a fixed map with no oxygen sensor feedback, so a trim figure there says nothing about a leak or a lazy sensor:
var fuelSystem = await connection.Execute(StandardCommands.FuelSystemStatus);
if (fuelSystem.IsClosedLoop)
RecordTrim(await connection.Execute(FuelTrimCommand.ShortTermBank1()));
Emissions Monitor Readiness
MonitorStatus decodes the whole of PID 0x01, not just the lamp. Monitors lists only the monitors
the vehicle actually supports β one that does not exist on a car has no readiness state worth
showing β and the bit layout differs between spark and compression ignition, which the decoder
selects for you.
var status = await connection.Execute(StandardCommands.MonitorStatus);
Console.WriteLine($"MIL: {status.MilOn}, {status.DtcCount} stored code(s)");
Console.WriteLine($"Ignition: {status.Ignition}");
if (!status.IsReadyForInspection)
Console.WriteLine($"Still running: {String.Join(", ", status.Incomplete.Select(x => x.Monitor))}");
A vehicle whose codes were recently cleared reads not-ready for several drive cycles with nothing
wrong with it β StandardCommands.TimeSinceCodesCleared (PID 0x4E) is what tells you which case
you are in. MonitorStatusThisDriveCycle (PID 0x41) reports the same monitors for the current
cycle only; its byte A is reserved, so its MilOn and DtcCount are always empty.
Freeze Frames (mode 02)
Mode 02 accepts the same PIDs as mode 01 and scales them identically, so there is no separate
command per reading β call AsFreezeFrame() on the mode 01 command you already have. What you get
back is the vehicle's state at the instant a code was set, rather than its state now.
var causal = await connection.Execute(FreezeFrameCommands.CausalDtc());
if (causal != null)
{
Console.WriteLine($"{causal} was set at:");
Console.WriteLine(await connection.Execute(StandardCommands.EngineRpm.AsFreezeFrame()));
Console.WriteLine(await connection.Execute(StandardCommands.CalculatedEngineLoad.AsFreezeFrame()));
Console.WriteLine(await connection.Execute(StandardCommands.CoolantTemperature.AsFreezeFrame()));
}
β οΈ Always check
CausalDtcfirst. When it answers null there is no stored snapshot, and every other mode 02 reading is meaningless rather than merely absent β the frame is zero-filled, so an engine load of 0% and a coolant temperature of -40 Β°C come back looking like measurements.
Supported PIDs
Querying an unsupported PID just returns NO DATA, so probe the bitmask blocks up front and offer only the readings the vehicle actually answers. Each block reports the 32 PIDs that follow it.
var supported = new HashSet<byte>();
foreach (var block in SupportedPidsCommand.BlockPids) // 00, 20, 40, 60, 80, A0, C0
{
var pids = await connection.Execute(new SupportedPidsCommand(block));
foreach (var pid in pids)
supported.Add(pid);
}
if (supported.Contains(0xA6))
odometerKm = await connection.Execute(StandardCommands.Odometer);
An unsupported reading should surface to your users as missing, not as zero β the odometer PID is absent on most vehicles, and hybrid battery life is absent on every vehicle without a pack.
Diagnostic Trouble Codes
DtcReadCommand returns SAE J2012 code strings ("P0301"). The CAN and pre-CAN response framings
are distinguished by payload parity, so the same command works across protocols.
var stored = await connection.Execute(DtcReadCommand.Stored); // mode 03 β these turn the MIL on
var pending = await connection.Execute(DtcReadCommand.Pending); // mode 07 β current/last drive cycle
var permanent = await connection.Execute(DtcReadCommand.Permanent); // mode 0A β only the ECU clears these
// Mode 04 also resets the emissions readiness monitors, which then take several drive cycles
// to re-run. Only ever issue this from an explicitly confirmed user action.
var cleared = await connection.Execute(ClearDtcCommand.Instance);
Custom Commands
Implement IObdCommand<T> directly for full control, or extend ObdCommand<T> for standard Mode/PID commands.
Extending ObdCommand<T> (standard Mode/PID pattern)
// Barometric pressure (Mode 01, PID 0x33) β single byte, value in kPa
public class BarometricPressureCommand : ObdCommand<int>
{
public BarometricPressureCommand() : base(0x01, 0x33) { }
protected override int ParseData(byte[] data) => data[0];
}
// Usage
var pressure = await connection.Execute(new BarometricPressureCommand());
The ObdCommand<T> base class automatically:
- Generates
RawCommandfrom Mode + PID (e.g."0133") - Validates the response header (mode echo + PID match)
- Strips the 2-byte header before calling your
ParseData
Implementing IObdCommand<T> (full control)
// Completely custom command with non-standard response format
public class CustomDiagnosticCommand : IObdCommand<string>
{
public string RawCommand => "2101"; // manufacturer-specific
public string Parse(byte[] data)
{
// You receive ALL response bytes β parse however you need
return BitConverter.ToString(data);
}
}
Adapter Profiles
Auto-Detection (default)
When you create ObdConnection(transport) without a profile, Connect() sends ATI to identify the adapter:
| ATI Response Contains | Detected As | Profile Used |
|---|---|---|
"ELM327" |
ObdAdapterType.Elm327 |
Elm327AdapterProfile |
"STN" |
ObdAdapterType.ObdLink |
ObdLinkAdapterProfile |
| Anything else | ObdAdapterType.Unknown |
Elm327AdapterProfile |
var connection = new ObdConnection(transport);
await connection.Connect();
// Check what was detected
Console.WriteLine(connection.DetectedAdapter?.RawIdentifier); // "ELM327 v1.5"
Console.WriteLine(connection.DetectedAdapter?.Type); // Elm327
Explicit Profile
Skip detection by providing a profile:
var connection = new ObdConnection(transport, new ObdLinkAdapterProfile());
await connection.Connect(); // uses OBDLink init, no ATI probe
Built-in Profiles
Elm327AdapterProfile β Standard initialization:
ATZ β Reset
ATE0 β Echo off
ATL0 β Linefeed off
ATS1 β Spaces on
ATH0 β Headers off
ATSP0 β Auto protocol
ObdLinkAdapterProfile β Extends ELM327 with STN-specific optimizations:
(all ELM327 commands above)
STFAC β Reset to factory defaults
ATCAF1 β CAN auto formatting on
Custom Profiles
public class MyAdapterProfile : IObdAdapterProfile
{
public string Name => "MyAdapter";
public async Task Initialize(IObdConnection connection, CancellationToken ct = default)
{
await connection.SendRaw("ATZ", ct);
await Task.Delay(500, ct);
await connection.SendRaw("ATE0", ct);
await connection.SendRaw("ATSP6", ct); // force CAN 11-bit 500kbaud
// ... any adapter-specific commands
}
}
Device Discovery
Before connecting, scan for available OBD adapters with IObdDeviceScanner:
using Shiny.Obd;
using Shiny.Obd.Ble;
var scanner = new BleObdDeviceScanner(bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBD"
});
var cts = new CancellationTokenSource();
await scanner.Scan(device =>
{
Console.WriteLine($"Found: {device.Name} ({device.Id})");
// device.NativeDevice is IPeripheral for BLE
}, cts.Token);
DeviceNameFilter is a case-insensitive substring match against the peripheral's name, falling back to the local name in the advertisement when the peripheral reports none β which is the normal case on iOS while scanning.
The scan is not filtered by ServiceUuid. iOS matches a scan filter against the advertisement only, and most ELM327 clones don't advertise their service, so a filtered scan would find nothing there.
Every advertisement seen is logged at Debug level before filtering β name, Peripheral.Name, id, RSSI and advertised service UUIDs β so an adapter that never reaches your callback can still be identified:
builder.Logging.AddDebug().SetMinimumLevel(LogLevel.Debug);
Each discovered device is an ObdDiscoveredDevice with Name, Id, and NativeDevice. Pass it directly to BleObdTransport:
var transport = new BleObdTransport(device, new BleObdConfiguration());
var connection = new ObdConnection(transport);
await connection.Connect();
DI Registration
Register BLE OBD services in one call:
using Shiny;
builder.Services.AddBluetoothLE(); // Shiny BLE platform registration
builder.Services.AddShinyObdBluetoothLE(new BleObdConfiguration
{
DeviceNameFilter = "OBD"
});
AddShinyObdBluetoothLE registers BleObdConfiguration and IObdDeviceScanner (BleObdDeviceScanner). You must also call AddBluetoothLE() for platform BLE support.
## BLE Transport
### Configuration
```csharp
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)
};
Using a Discovered Device
Use BleObdDeviceScanner to find adapters, then pass the selected device directly:
ObdDiscoveredDevice device = /* from scanner */;
var transport = new BleObdTransport(device, new BleObdConfiguration());
Using a Pre-Scanned Peripheral
If you've already discovered the BLE peripheral (e.g. from a scan UI):
IPeripheral peripheral = /* from your scan */;
var transport = new BleObdTransport(peripheral, new BleObdConfiguration());
Auto-Scan
Let the transport scan for the first matching device:
IBleManager bleManager = /* from DI */;
var transport = new BleObdTransport(bleManager, new BleObdConfiguration
{
DeviceNameFilter = "OBDII"
});
Raw Commands
Send arbitrary AT or OBD commands:
// AT commands
var version = await connection.SendRaw("ATI"); // "ELM327 v1.5"
var protocol = await connection.SendRaw("ATDPN"); // current protocol number
var voltage = await connection.SendRaw("ATRV"); // battery voltage
// Raw OBD hex commands
var response = await connection.SendRaw("0100"); // supported PIDs [01-20]
Error Handling
ObdException is thrown for adapter-level errors:
try
{
var speed = await connection.Execute(StandardCommands.VehicleSpeed);
}
catch (ObdException ex) when (ex.Message.Contains("No data"))
{
// Vehicle not responding to this PID (engine off, unsupported PID, etc.)
}
catch (ObdException ex) when (ex.Message.Contains("Unable to connect"))
{
// Adapter can't reach the vehicle ECU
}
The ObdCommand<T> base class also validates response headers and throws ObdException on mode/PID mismatches.
Timeouts
When an adapter stops answering, the transport throws ObdTimeoutException β an ObdException, deliberately not an OperationCanceledException:
try
{
var speed = await connection.Execute(StandardCommands.VehicleSpeed, ct);
}
catch (ObdTimeoutException ex)
{
// ex.Command / ex.Timeout β the adapter went quiet, but we are still running
}
catch (OperationCanceledException)
{
// our own token fired β we are shutting down
}
The distinction matters most to a polling loop. If a transport reported its own deadline as a cancellation, a single slow reply would be indistinguishable from a shutdown request and would tear the loop down. Catch the timeout, skip that reading, and keep going β and if timeouts keep coming, rebuild the connection rather than assuming the session is still good, because a BLE adapter stays linked long after it stops talking to the vehicle.
Implementing a Custom Transport
Implement IObdTransport to add WiFi, USB, or any other communication channel:
public class WifiObdTransport : IObdTransport
{
public bool IsConnected { get; private set; }
public async Task Connect(CancellationToken ct = default)
{
// Connect to ELM327 WiFi adapter (typically 192.168.0.10:35000)
}
public Task Disconnect() { /* ... */ }
public async Task Send(string command, CancellationToken ct = default)
{
// Send command, collect response until '>' prompt, return the text
}
public ValueTask DisposeAsync() { /* ... */ }
}
The Send method must:
- Write the command string to the adapter
- Read the response until the
>prompt character - Return the response text (without the
>prompt)
Changes from Previous Version
Adoption Assessment
A verified publisher signal is available. Assess fit from the package's supported frameworks, license and dependency graph rather than popularity alone.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.