Shiny.Obd Skill
You are an expert in Shiny.Obd, a .NET library for communicating with vehicles through OBD-II adapters. It uses a command-object pattern with generic return types, pluggable transports (BLE, WiFi, serial), and adapter auto-detection for ELM327 and OBDLink (STN) adapters.
When to Use This Skill
Invoke this skill when the user wants to:
- Read vehicle data (speed, RPM, coolant temp, VIN, etc.) through OBD-II
- Create custom OBD commands with typed return values
- Connect to an OBD-II adapter over Bluetooth LE, WiFi or serial (USB/UART)
- Scan for / discover available OBD adapters
- Configure ELM327 or OBDLink adapter initialization
- Implement a custom transport (Android USB Host, J2534, a replay harness) for OBD communication
- Send raw AT commands to an OBD adapter
- Handle OBD response parsing and error handling
- Build a MAUI app with OBD integration
Library Overview
- Repository: https://github.com/shinyorg/obd
- Namespaces:
Shiny.Obd,Shiny.Obd.Ble,Shiny.Obd.Wifi,Shiny.Obd.Serial,Shiny.Obd.Commands,Shiny.Obd.Emulator - NuGet:
Shiny.Obd(core),Shiny.Obd.Ble(BLE),Shiny.Obd.Wifi(WiFi/TCP),Shiny.Obd.Serial(USB/UART),Shiny.Obd.Emulator(+.Ble) — be an adapter instead of reading one - Targets:
net10.0throughout
Core Types
IObdCommand — Command interface
Every OBD command implements this. T is the parsed result type.
public interface IObdCommand<T>
{
string RawCommand { get; }
T Parse(byte[] data);
}
ObdCommand — Base class for standard Mode/PID commands
Validates mode+PID response header, strips it, and delegates to ParseData.
public abstract class ObdCommand<T> : IObdCommand<T>
{
protected ObdCommand(byte mode, byte pid);
public byte Mode { get; }
public byte Pid { get; }
public virtual string RawCommand { get; } // "{Mode:X2}{Pid:X2}"
protected abstract T ParseData(byte[] data); // data after header
}
IObdConnection — Connection interface
public interface IObdConnection : IAsyncDisposable
{
bool IsConnected { get; }
Task Connect(CancellationToken ct = default);
Task Disconnect();
Task<T> Execute<T>(IObdCommand<T> command, CancellationToken ct = default);
Task<string> SendRaw(string command, CancellationToken ct = default);
}
IObdTransport — Transport abstraction
public interface IObdTransport : IAsyncDisposable
{
bool IsConnected { get; }
Task Connect(CancellationToken ct = default);
Task Disconnect();
Task<string> Send(string command, CancellationToken ct = default);
}
Three implementations ship: BleObdTransport (Shiny.Obd.Ble), WifiObdTransport
(Shiny.Obd.Wifi) and SerialObdTransport (Shiny.Obd.Serial). All three also implement
IDisposable so a container can tear them down on its synchronous path.
IObdDeviceScanner — Device discovery
public interface IObdDeviceScanner
{
Task Scan(Action<ObdDiscoveredDevice> onDeviceFound, CancellationToken ct = default);
}
Cancel the token to stop scanning. Each discovered device invokes the callback.
ObdDiscoveredDevice — Discovered adapter
public class ObdDiscoveredDevice
{
public string Name { get; } // e.g. "OBDLink MX+"
public string Id { get; } // unique identifier (BLE UUID, IP, etc.)
public object NativeDevice { get; } // IPeripheral for BLE, IPEndPoint for WiFi, etc.
}
ObdConnection — ELM327 protocol handler
Two constructors:
ObdConnection(IObdTransport transport)— auto-detects adapter via ATIObdConnection(IObdTransport transport, IObdAdapterProfile profile)— uses explicit profile, skips detection
Properties:
DetectedAdapter—ObdAdapterInfo?withRawIdentifier(string) andType(ObdAdapterType enum: Unknown, Elm327, ObdLink). Null when explicit profile used.Protocol—string?, settable beforeConnect. The ELM protocol number to pin withATSPinstead of searching. Ignored when an explicit profile was supplied — pass it to the profile's constructor instead.NegotiatedProtocol—string?, the protocol the adapter reports it is on. Refreshed byConnectand byRefreshNegotiatedProtocol().
Methods:
RefreshNegotiatedProtocol(CancellationToken)— re-readsATDPNand updatesNegotiatedProtocol.
Always pin the protocol on reconnect. ATSP0 does not choose a protocol — it defers the choice to
the first command that needs the bus, and that command pays the whole ELM search: seconds of it,
routinely longer than a command timeout. ATZ discards the result, so an unpinned adapter pays it
again on every reconnect. Generate this pattern whenever an app reconnects to a remembered adapter:
var connection = new ObdConnection(transport) { Protocol = savedProtocol }; // null on a first run
await connection.Connect();
// ⚠️ Ask AFTER something has needed the bus. ATSP0 has chosen nothing at the end of Connect, so
// asking there reports null and the app never learns a number to save.
await connection.Execute(new SupportedPidsCommand(0x00));
savedProtocol = await connection.RefreshNegotiatedProtocol();
A stale pin is safe — it is verified with mode 01 during initialization and dropped for a search when nothing answers.
Handles:
- ELM327 hex response parsing (single-line and multi-frame CAN)
- Multi-frame framing: discards the leading byte-count line and the
N:frame index, then concatenates frames in order - Spaced and unspaced hex alike (
0: 49 02 01and0:490201) - Error detection: "NO DATA", "UNABLE TO CONNECT", "BUS INIT: ...ERROR", "?"
- Strips "SEARCHING..." and "BUS INIT" prefixes
IObdAdapterProfile — Adapter initialization
public interface IObdAdapterProfile
{
string Name { get; }
Task Initialize(IObdConnection connection, CancellationToken ct = default);
}
Built-in profiles:
Elm327AdapterProfile(string? protocol = null)— ATZ, ATE0, ATL0, ATS1, ATH0, thenATSP{protocol}orATSP0ObdLinkAdapterProfile(string? protocol = null)— STFAC, then the Elm327 sequence, then ATCAF1
ATZ is sent once per connect, by the profile. Auto-detection probes with ATI alone and does not
reset first. STFAC restores factory defaults, so it precedes the ELM327 configuration rather than
following it.
Standard Commands (StandardCommands static class)
| Property | Type | Command | Return | Parse Formula |
|---|---|---|---|---|
VehicleSpeed |
VehicleSpeedCommand |
010D |
int (km/h) |
A |
EngineRpm |
EngineRpmCommand |
010C |
int (RPM) |
((A*256)+B)/4 |
CoolantTemperature |
CoolantTemperatureCommand |
0105 |
int (°C) |
A-40 |
ThrottlePosition |
ThrottlePositionCommand |
0111 |
double (%) |
(A*100)/255 |
FuelLevel |
FuelLevelCommand |
012F |
double (%) |
(A*100)/255 |
CalculatedEngineLoad |
CalculatedEngineLoadCommand |
0104 |
double (%) |
(A*100)/255 |
IntakeAirTemperature |
IntakeAirTemperatureCommand |
010F |
int (°C) |
A-40 |
RuntimeSinceStart |
RuntimeSinceStartCommand |
011F |
TimeSpan |
(A*256)+B seconds |
Vin |
VinCommand |
0902 |
string |
skip count byte, ASCII decode |
Odometer |
OdometerCommand |
01A6 |
double (km) |
((A<<24)|(B<<16)|(C<<8)|D)/10 |
DistanceSinceCodesCleared |
DistanceSinceCodesClearedCommand |
0131 |
int (km) |
(A*256)+B |
ControlModuleVoltage |
ControlModuleVoltageCommand |
0142 |
double (V) |
((A*256)+B)/1000 |
MassAirFlow |
MassAirFlowCommand |
0110 |
double (g/s) |
((A*256)+B)/100 |
EngineFuelRate |
EngineFuelRateCommand |
015E |
double (L/h) |
((A*256)+B)/20 |
EngineOilTemperature |
EngineOilTemperatureCommand |
015C |
int (°C) |
A-40 |
FuelType |
FuelTypeCommand |
0151 |
byte |
A (J1979 code) |
HybridBatteryLife |
HybridBatteryLifeCommand |
015B |
double (%) |
(A*100)/255 |
MonitorStatus |
MonitorStatusCommand |
0101 |
MonitorStatus |
see readiness section below |
MonitorStatusThisDriveCycle |
MonitorStatusThisDriveCycleCommand |
0141 |
MonitorStatus |
same layout; byte A reserved |
FuelSystemStatus |
FuelSystemStatusCommand |
0103 |
FuelSystemStatus |
enumerated loop state, A and optional B |
IntakeManifoldPressure |
IntakeManifoldPressureCommand |
010B |
int (kPa) |
A |
BarometricPressure |
BarometricPressureCommand |
0133 |
int (kPa) |
A |
TimingAdvance |
TimingAdvanceCommand |
010E |
double (° BTDC) |
(A/2)-64 |
AmbientAirTemperature |
AmbientAirTemperatureCommand |
0146 |
int (°C) |
A-40 |
RelativeAcceleratorPedalPosition |
RelativeAcceleratorPedalPositionCommand |
015A |
double (%) |
(A*100)/255 |
CommandedThrottleActuator |
CommandedThrottleActuatorCommand |
014C |
double (%) |
(A*100)/255 |
DistanceWithMilOn |
DistanceWithMilOnCommand |
0121 |
int (km) |
(A*256)+B |
TimeRunWithMilOn |
TimeRunWithMilOnCommand |
014D |
TimeSpan |
(A*256)+B minutes |
TimeSinceCodesCleared |
TimeSinceCodesClearedCommand |
014E |
TimeSpan |
(A*256)+B minutes |
CalibrationId |
CalibrationIdCommand |
0904 |
IReadOnlyList<string> |
16-byte ASCII blocks, null-padded |
CommandedAirFuelRatio |
CommandedAirFuelRatioCommand |
0144 |
double (lambda) |
2/65536*((A*256)+B) |
CommandedEgr |
CommandedEgrCommand |
012C |
double (%) |
(A*100)/255 |
EgrError |
EgrErrorCommand |
012D |
double (%) |
(A*100/128)-100 |
CommandedEvaporativePurge |
CommandedEvaporativePurgeCommand |
012E |
double (%) |
(A*100)/255 |
EvapVaporPressure |
EvapVaporPressureCommand |
0132 |
double (Pa) |
signed ((A*256)+B)/4 |
AbsoluteEvapVaporPressure |
AbsoluteEvapVaporPressureCommand |
0153 |
double (kPa) |
((A*256)+B)/200 |
EvapVaporPressureWideRange |
EvapVaporPressureWideRangeCommand |
0154 |
double (Pa) |
signed (A*256)+B |
DriverDemandTorque |
DriverDemandTorqueCommand |
0161 |
int (%) |
A-125 |
ActualEngineTorque |
ActualEngineTorqueCommand |
0162 |
int (%) |
A-125 |
ReferenceTorque |
ReferenceTorqueCommand |
0163 |
int (N·m) |
(A*256)+B |
EnginePercentTorqueData |
EnginePercentTorqueDataCommand |
0164 |
EnginePercentTorqueData |
five points, each X-125 |
FuelPressure |
FuelPressureCommand |
010A |
int (kPa) |
A*3 |
FuelRailPressure |
FuelRailPressureCommand |
0122 |
double (kPa) |
0.079*((A*256)+B) |
FuelRailGaugePressure |
FuelRailGaugePressureCommand |
0123 |
int (kPa) |
10*((A*256)+B) |
FuelRailAbsolutePressure |
FuelRailAbsolutePressureCommand |
0159 |
int (kPa) |
10*((A*256)+B) |
EthanolFuelPercent |
EthanolFuelPercentCommand |
0152 |
double (%) |
(A*100)/255 |
AbsoluteLoadValue |
AbsoluteLoadValueCommand |
0143 |
double (%) |
((A*256)+B)*100/255 — not capped at 100 |
WarmUpsSinceCodesCleared |
WarmUpsSinceCodesClearedCommand |
0130 |
int (count) |
A |
RelativeThrottlePosition |
RelativeThrottlePositionCommand |
0145 |
double (%) |
(A*100)/255 |
FuelInjectionTiming |
FuelInjectionTimingCommand |
015D |
double (°) |
(((A*256)+B)/128)-210 |
EngineRunTime |
EngineRunTimeCommand |
017F |
EngineRunTime |
support byte, then 3 x 4-byte second counters |
ObdStandards |
ObdStandardsCommand |
011C |
byte |
raw J1979 code; name via ObdStandards.Describe |
CalibrationVerificationNumber |
CalibrationVerificationNumberCommand |
0906 |
IReadOnlyList<string> |
4-byte blocks as hex strings |
EcuName |
EcuNameCommand |
090A |
string |
20-byte ASCII, null-padded |
⚠️ PIDs 4D/4E are minutes; RuntimeSinceStart (011F) is seconds. Do not copy one
formula onto the other.
⚠️ Do not confuse the four position PIDs. ThrottlePosition (0111) is absolute throttle plate
position, the drive-by-wire output, and it carries a closed-pedal floor of 12-18% that varies by
vehicle. AcceleratorPedalPositionCommand (0149/014A/014B) and
RelativeAcceleratorPedalPosition (015A) are the driver's input, and 015A is already
normalised so a released pedal reads 0. When measuring how hard a vehicle is being driven, prefer
015A, then a pedal sensor, and only fall back to 0111 — never assume 0111 bottoms out at zero.
⚠️ AmbientAirTemperature (0146) is the air outside; IntakeAirTemperature (010F) is what the
engine is breathing, measured after the engine bay has warmed it. They are not interchangeable.
⚠️ RelativeThrottlePosition (0145) is the one to put on a UI — it is referenced to the learned
closed stop and reads 0% at rest, where ThrottlePosition (0111) never does.
⚠️ AbsoluteLoadValue (0143) is not capped at 100% — a boosted engine reads well above it, up to
~400%. Never clamp it, and never substitute it for CalculatedEngineLoad (0104) or vice versa.
⚠️ The two signed EVAP pressure PIDs (0132, 0154) must be read as two's complement. Reading them
unsigned turns every vacuum — the entire point of the measurement — into a large positive pressure.
Commands not on StandardCommands
These need construction data or carry their own shared instances, so never write
StandardCommands.FuelTrim or StandardCommands.SupportedPids — they do not exist.
| Command | Construct with | Return | Notes |
|---|---|---|---|
FuelTrimCommand |
FuelTrimCommand.ShortTermBank1() / .LongTermBank1() / .ShortTermBank2() / .LongTermBank2() (PIDs 06/07/08/09), or new FuelTrimCommand(pid) |
double (%) |
(A*100/128)-100. 128 is zero correction; positive = ECU adding fuel |
SupportedPidsCommand |
new SupportedPidsCommand(block) for each of SupportedPidsCommand.BlockPids |
IReadOnlyList<byte> |
The 32 PIDs following the block PID that the vehicle answers |
AcceleratorPedalPositionCommand |
AcceleratorPedalPositionCommand.D() / .E() / .F() (PIDs 49/4A/4B), or new AcceleratorPedalPositionCommand(pid) |
double (%) |
(A*100)/255. Redundant sensors on one pedal; most vehicles report D and E |
DtcReadCommand |
DtcReadCommand.Stored (03) / .Pending (07) / .Permanent (0A) |
IReadOnlyList<string> |
SAE J2012 strings, e.g. "P0301" |
ClearDtcCommand |
ClearDtcCommand.Instance (04) |
bool |
True when the ECU answers 0x44 |
| mode 02 readings | someModeOneCommand.AsFreezeFrame(frame) |
same as the mode 01 command | Never write a separate mode 02 command class |
FreezeFrameCommands.CausalDtc(frame) |
static factory | string? |
The code that stored the frame; null = no snapshot |
OxygenSensorsPresentCommand |
.TwoBanks() (PID 13) / .FourBanks() (PID 1D) |
OxygenSensorLayout |
Read before any sensor PID — it decides what they mean |
OxygenSensorVoltageCommand |
.Sensor(1..8) (PIDs 14-1B) |
OxygenSensorVoltage |
Narrowband. A/200 V; trim 100/128*B-100, null when B == 0xFF |
OxygenSensorLambdaCommand |
.WithVoltage(1..8) (24-2B) / .WithCurrent(1..8) (34-3B) |
OxygenSensorLambda |
Wideband. Lambda 2/65536*(256A+B) |
AbsoluteThrottlePositionCommand |
.B() (47) / .C() (48) |
double (%) |
Redundant drive-by-wire sensors; disagreement sets correlation codes |
InUsePerformanceTrackingCommand |
.Spark() (09 08) / .Compression() (09 0B) |
InUsePerformanceTracking |
Pick by engine type; the wrong one returns NO DATA |
OnBoardTestCommand |
new OnBoardTestCommand(mid) |
IReadOnlyList<OnBoardTestResult> |
Mode 06. One MID commonly answers several records |
OnBoardTestSupportedMidsCommand |
new OnBoardTestSupportedMidsCommand(block) for each of MonitorIds.BlockMids |
IReadOnlyList<byte> |
Mode 06 discovery |
Oxygen sensors — always read the layout first
Never generate a per-sensor read without first reading OxygenSensorsPresentCommand. This is not
about skipping absent sensors. A vehicle answers PID 0x13 (two banks of four) or PID 0x1D
(four banks of two), never both, and the choice changes what every sensor PID means:
- Under
0x13, PID0x16is bank 1, sensor 3. - Under
0x1D, PID0x16is bank 2, sensor 1.
Labelling from the wrong layout sends someone to the wrong side of the engine. Use
layout.Position(sensorIndex) for the label — never compute bank/sensor arithmetic inline.
var layout = await connection.Execute(OxygenSensorsPresentCommand.TwoBanks());
foreach (var sensor in layout.Sensors)
{
var reading = await connection.Execute(OxygenSensorVoltageCommand.Sensor(sensor.SensorIndex));
Console.WriteLine($"{sensor}: {reading.Volts:F3} V"); // sensor.ToString() is "B1S2"
}
Narrowband (OxygenSensorVoltageCommand, 14-1B) and wideband (OxygenSensorLambdaCommand, 24-2B or
34-3B) are different measurements. Do not compare their voltages and do not offer both for the
same sensor without probing SupportedPidsCommand.
ShortTermFuelTrim is null when the vehicle sets 0xFF (sensor not used in trim). Never coerce that
to 0 or to 99.2 — it is an absence.
When generating diagnostic guidance: a healthy upstream narrowband oscillates ~0.1-0.9 V several times a second when hot (a mid-range reading means a lazy sensor, not a perfect mixture); a downstream sensor sits steady ~0.6-0.7 V and mirroring the upstream swing means the catalyst is spent. Always sample over seconds — never diagnose from one reading.
EGR and EVAP
Generate CommandedEgr and EgrError together. Commanded alone says only what was asked for.
The three EVAP pressure PIDs are not interchangeable and must never be converted between:
| PID | Command | Unit | Reference |
|---|---|---|---|
| 32 | EvapVaporPressure |
signed Pa, ±8 kPa | atmosphere |
| 54 | EvapVaporPressureWideRange |
signed Pa, ±32 kPa | atmosphere |
| 53 | AbsoluteEvapVaporPressure |
unsigned kPa | vacuum — ~101 kPa is atmospheric |
Probe with SupportedPidsCommand and use whichever the vehicle answers.
Torque and power
Torque PIDs are percentages of a reference figure — neither is meaningful alone. Generate a read of
ReferenceTorque once, outside any poll loop (it is a constant for the engine), then use
EnginePower:
var reference = await connection.Execute(StandardCommands.ReferenceTorque); // once
// per sample:
var kw = EnginePower.Kilowatts(
await connection.Execute(StandardCommands.ActualEngineTorque),
reference,
await connection.Execute(StandardCommands.EngineRpm)
);
Offer MetricHorsepower (PS, 735.5 W) or MechanicalHorsepower (hp, 745.7 W) explicitly — never
write a bare "horsepower" conversion, since the two differ by ~1.4% and silently disagreeing about a
car's output is worse than asking. Negative torque is normal (overrun). This is flywheel output, not
wheel output — never present it as a dyno figure.
Mode 06 — on-board test results
The only mode that answers "how close is this to failing". Generate discovery first — there are 224 MIDs and an unsupported one returns NO DATA:
foreach (var block in MonitorIds.BlockMids) // 00, 20, 40, 60, 80, A0
{
foreach (var mid in await connection.Execute(new OnBoardTestSupportedMidsCommand(block)))
{
foreach (var test in await connection.Execute(new OnBoardTestCommand(mid)))
{
if (test.Passed == false)
Console.WriteLine($"{test.Monitor}: {test.Value} {test.Unit} outside {test.Minimum}-{test.Maximum}");
}
}
}
Rules:
- Never decode a mode 06 value without
UnitAndScaling. The same 16-bit number is 0.25 rpm/bit under one identifier and 0.122 mV under another.OnBoardTestResult.Valuealready applies it. - Never treat a raw value as unsigned by default. Identifiers
0x80and above are signed; reading one unsigned turns a small negative into ~65,535 and a passing test into a dramatic failure. Value,Unit,PassedandBandPositionare null when the identifier is outside the standard table. Surface that as unknown;RawValue/RawMinimum/RawMaximumare still there.BandPosition(0 at the lower limit, 1 at the upper) is the feature worth building on — a passing test at 0.95 trended over time is a component you can schedule. Prefer it over a bare pass/fail UI.Monitoris null for MIDs above0xDF(manufacturer-defined). Do not invent a name.- Mode 06 is CAN-only here. On a pre-CAN vehicle it throws
ObdExceptionnaming that as the cause.
ECU identity and in-use performance
CalibrationId (09 04) and CalibrationVerificationNumber (09 06) are a pair — generate both when
the goal is tune/reflash detection. The CVN is computed over the calibration itself, so a reflash that
keeps the same ID still changes it. CVNs are returned as uppercase hex strings; never parse them to a
number (leading zeroes are significant and they have no arithmetic meaning).
InUsePerformanceTrackingCommand.Spark() / .Compression() — pick by engine type
(MonitorStatus.Ignition or ObdStandards.IsHeavyDuty tells you which). Ratio is null when the
denominator is zero: "never had the opportunity" is a different finding from "had the opportunity and
never ran", and only the second is a vehicle problem.
Reading a J1979 fuel type
FuelTypeCommand returns a raw code; FuelTypes.Describe names it. It answers null for 0x00
("not available") and for anything outside the table — never the string "Unknown", because a caller
storing or displaying this has to be able to tell an absent answer from a claim about the vehicle.
Read it once per connection, not on a poll: a vehicle does not change what it burns.
var fuelType = FuelTypes.Describe(await connection.Execute(StandardCommands.FuelType));
Probing supported PIDs first
Querying an unsupported PID returns NO DATA, so read the bitmask blocks once per connection and only poll what the vehicle answers. An unsupported reading must surface as missing, not zero — the odometer PID is absent on most vehicles and hybrid battery life on every vehicle without a pack, and a zero there is indistinguishable from a dead pack.
var supported = new HashSet<byte>();
foreach (var block in SupportedPidsCommand.BlockPids) // 00, 20, 40, 60, 80, A0, C0
{
foreach (var pid in await connection.Execute(new SupportedPidsCommand(block)))
supported.Add(pid);
}
Reading and clearing trouble codes
var stored = await connection.Execute(DtcReadCommand.Stored); // mode 03 — turn the MIL on
var pending = await connection.Execute(DtcReadCommand.Pending); // mode 07
var permanent = await connection.Execute(DtcReadCommand.Permanent); // mode 0A — ECU clears these
var cleared = await connection.Execute(ClearDtcCommand.Instance); // mode 04
Modes 03/07/0A/04 carry no PID, so these implement IObdCommand<T> directly rather than
extending ObdCommand<T> — follow that pattern for any other PID-less mode. DtcDecoder is the
public parser behind them: it strips the mode echo, then uses payload parity to tell a CAN
reply (43 <count> <pairs>) from a pre-CAN one (43 <pairs>) rather than assuming a transport.
⚠️ Mode 04 also resets the emissions readiness monitors, which then take several drive cycles to re-run and will fail an emissions test in the meantime. Always put it behind an explicit user confirmation that says so — never call it as part of a connect or refresh routine.
Emissions monitor readiness
MonitorStatus decodes all four bytes of PID 0x01, not just the lamp. MonitorStatusDecoder is the
public parser and is testable without a transport.
var status = await connection.Execute(StandardCommands.MonitorStatus);
if (status.IsReadyForInspection == false)
Report(status.Incomplete.Select(x => x.Monitor));
Rules that matter when generating code against this:
Monitorscontains only the monitors the vehicle supports. Do not write code that treats a missing monitor as incomplete — a monitor that does not exist on a car has no readiness state.- The completion bits are inverted in the raw bytes (a set bit means the test is still running).
MonitorReadiness.Completehas already flipped it; never flip it again. - Which monitors bytes C/D describe depends on
IgnitionType, taken from bit 3 of byte B. The same bit means different monitors on a petrol and a diesel — the decoder handles it, so never index the bytes yourself. EmissionMonitor.GasolineParticulateFilteris that bit's meaning under ISO 15031-5:2015 and later; earlier revisions defined it as A/C refrigerant monitoring.- A vehicle reads not-ready for several drive cycles after codes are cleared with nothing wrong with
it. Pair it with
TimeSinceCodesCleared(PID 0x4E) before telling a user their car has a problem. MonitorStatusThisDriveCycle(PID 0x41) shares the type, but byte A is reserved — itsMilOnandDtcCountare always false/0 and must not be surfaced.
Freeze frames (mode 02)
Mode 02 accepts the same PIDs as mode 01 and scales them identically. Never write a separate mode
02 command class — call AsFreezeFrame() on the mode 01 command.
var causal = await connection.Execute(FreezeFrameCommands.CausalDtc());
if (causal != null)
{
var rpm = await connection.Execute(StandardCommands.EngineRpm.AsFreezeFrame());
var load = await connection.Execute(StandardCommands.CalculatedEngineLoad.AsFreezeFrame());
}
⚠️ Always gate mode 02 reads on CausalDtc returning non-null. When there is no stored snapshot
the frame is zero-filled, so an engine load of 0% and a coolant temperature of -40 °C come back
looking like measurements rather than like an absence.
The mode 02 header is three bytes (42 <PID> <frame>), not two, because the frame number is
echoed. AsFreezeFrame throws ObdException on a non-mode-01 command — mode 09 identifiers are not
sampled at a moment, so there is no frame to ask for.
VIN Decoding (Shiny.Obd.Vin)
Registration, and the only two forms that exist:
services.AddVinDecoder(); // built-in NHTSA vPIC
services.AddVinDecoder<MyRegistryVinDecoder>(); // your own IVinDecoder
AddVinDecoder also calls AddHttpClient() and registers with TryAddSingleton, so the first
registration wins. Do not hand-register VpicVinDecoder; do not resolve it by concrete type.
var vin = await connection.Execute(StandardCommands.Vin); // mode 09 PID 02
var vehicle = await vinDecoder.Decode(vin); // null when it cannot be identified
VinVehicle (provider-neutral, every field nullable):
| Property | Type | Bounds |
|---|---|---|
Make / Model / Trim |
string? |
|
ModelYear |
int? |
1900-2100 |
FuelType / Electrification |
string? |
|
EngineCylinders |
int? |
1-16 |
EngineDisplacementLitres |
double? |
0-20 |
EngineHorsepower |
int? |
1-2000 |
DriveType / BodyClass / TransmissionStyle |
string? |
|
IsUsable |
bool |
make or model identified |
Rules that matter when generating code against this:
Never re-parse or re-clean the result. The values arrive typed, bounded and with the registries'
"Not Applicable"/"Not Available"/"N/A"placeholders already stripped to null. Writing a second cleaning pass in the consumer is the mistake this shape exists to prevent.Null means the registry had nothing. Never substitute
"Unknown"or0— these values reach users and AI prompts, where a placeholder reads as a fact about the car.IVinDecodernever throws, by contract. Do not wrap calls in try/catch for control flow, and any implementation you write must return null rather than throwing or guessing.Coverage falls off outside North America. A decode with a make and model and nothing else is a success, not a failure — do not treat a missing displacement as an error.
VinNumber.IsPlausible/Normalizeare the pure pre-check (17 chars, no I/O/Q).Decodeapplies them itself, so only call them directly when you need to know why nothing came back.The check digit is a separate, opt-in pair — never add it to a read path.
VinNumber.CalculateCheckDigit(vin)returns the position-9 character ('0'–'9'or'X', or null when the input is implausible), andIsCheckDigitValid(vin)compares it to what is there. It is deliberately outsideIsPlausiblebecause the check digit is only mandatory in North America, so plenty of legitimate European and Asian VINs fail it. Use it for user-typed VINs (catching a transposition) and for generating VINs that a decoder will accept — never to reject a VIN read off a vehicle.// a VIN for a test fixture: real WMI + descriptor, computed check digit var vin = "3VWRA7AU" + VinNumber.CalculateCheckDigit("3VWRA7AU0FM024518") + "FM024518";Mode 01 PID 0x51 outranks the registry for fuel type.
FuelTypes.Describeoff the bus needs no network and is the more trustworthy source on a rebadged or grey-import vehicle:var fuel = fromBus ?? vehicle?.FuelType;
BLE Transport (Shiny.Obd.Ble)
BleObdConfiguration
public class BleObdConfiguration
{
public string ServiceUuid { get; set; } = "FFF0";
public string ReadCharacteristicUuid { get; set; } = "FFF1";
public string WriteCharacteristicUuid { get; set; } = "FFF2";
public string? DeviceNameFilter { get; set; }
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(30);
public bool AutoConnect { get; set; } // default false
}
Leave AutoConnect off for an adapter somebody is waiting on, which is the normal OBD case. On
Android it selects ConnectGatt(autoConnect: true), the background connection path, where the
controller only attempts during widely spaced scan windows — tens of seconds for an adapter a direct
connect reaches in a few hundred milliseconds. It also arms the platform's own reconnect, which races
any caller supervising the session itself. Turn it on only when nothing in the app is doing that job.
The transport reads the write characteristic's advertised properties on connect and writes with or without a GATT response accordingly; do not assume either.
BleObdTransport
Three constructors:
BleObdTransport(IBleManager bleManager, BleObdConfiguration config)— scans for adapterBleObdTransport(IPeripheral peripheral, BleObdConfiguration config)— uses pre-discovered peripheralBleObdTransport(ObdDiscoveredDevice device, BleObdConfiguration config)— uses device from scanner
Uses Shiny.BluetoothLE v4 APIs:
ConnectAsyncfor task-based connectionNotifyCharacteristicfor RX notificationsWriteCharacteristicAsyncfor TX writes- Collects notification bytes until
>prompt, returns complete response
An exchange that hits CommandTimeout throws ObdTimeoutException and is then closed off — a late
reply arriving afterwards is discarded rather than completing the next command's wait. Disconnect()
fails an in-flight command straight away instead of making the caller wait out the timeout.
BLE scanning rules
Three rules the library already follows internally. Generated code that scans with IBleManager
directly must follow them too, or it will not find adapters on iOS.
1. Never match on IPeripheral.Name alone. On iOS CBPeripheral.Name is null while scanning a
peripheral that has never been connected to — the name is only in the advertisement. Always fall
back:
var name = scanResult.Peripheral.Name ?? scanResult.AdvertisementData?.LocalName;
2. Never pass the adapter's ServiceUuid as a scan filter. iOS matches a scan filter against the
advertisement, and most ELM327 clones don't advertise their GATT service — it appears only after
connecting. bleManager.Scan(new ScanConfig("FFF0")) finds nothing at all on iPhone. Scan
unfiltered and use ServiceUuid after connecting.
3. Never require a name, and never identify a remembered adapter by one. Rule 1 makes the name
better; it does not make it reliable. Plenty of ELM327 clones advertise no name at all, and on iOS
even the fallback is empty until CoreBluetooth has connected to that peripheral once and cached it —
so where(x => !string.IsNullOrEmpty(name)) is in practice a first-connection-of-the-process
filter. The symptom is distinctive and easy to misread: pairing works, the first reconnect after a
cold start fails, and every reconnect after that succeeds.
// WRONG - drops unnamed adapters, and on iOS that is most of them on the first run
.Where(x => !string.IsNullOrEmpty(x.Name))
// RIGHT - the peripheral id is the only key always present
.Where(x => x.Peripheral.Uuid == rememberedId)
Persist IPeripheral.Uuid for a remembered adapter and match on that. Use a name filter only when
the user asked to narrow by name — then excluding unnamed devices is correct, because a filter
cannot match a name that isn't there. BleObdDeviceScanner surfaces unnamed adapters with Name as
an empty string, so a picker should fall back to the id or RSSI for the row label.
BleObdDeviceScanner logs every advertisement at Debug level before filtering (name,
Peripheral.Name, id, RSSI, advertised service UUIDs). When a user reports that an adapter isn't
found, tell them to enable debug logging (builder.Logging.AddDebug().SetMinimumLevel(LogLevel.Debug))
and read that dump rather than guessing at UUIDs.
ObdTimeoutException
public class ObdTimeoutException : ObdException
{
public string Command { get; } // the command that went unanswered
public TimeSpan Timeout { get; } // the deadline that elapsed
}
Thrown when the adapter does not answer within the transport's CommandTimeout. Not an
OperationCanceledException — generated polling code must be able to tell a quiet adapter apart from
its own cancellation token firing:
try
{
reading = await connection.Execute(StandardCommands.VehicleSpeed, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // our own shutdown
}
catch (ObdException)
{
reading = null; // this PID is a write-off, the loop carries on
}
Registration (DI)
// WiFi — every platform, including iOS and Android
services.AddShinyObdWifi();
// Serial — Windows, Linux, macOS, Mac Catalyst
services.AddShinyObdSerial(config => config.PortNameFilter = "OBDLink");
// BLE — all platforms Shiny.BluetoothLE supports
services.AddShinyObdBluetoothLE();
Each registers IObdTransport, IObdConnection, IObdDeviceScanner and their configuration as
singletons, using TryAdd.
Picking a transport when the user has not said which. Ask what adapter they have rather than guessing. If they name a target platform but no adapter:
| Target | Default to | Why |
|---|---|---|
| iOS | WiFi or BLE | Serial is PlatformNotSupportedException |
| Android | WiFi or BLE | Serial compiles and then fails at runtime — see below |
| Raspberry Pi / fleet device | Serial | Wired, no pairing, cannot wander out of range |
| Windows / macOS / Linux desktop | Serial or WiFi | Whatever adapter is on hand |
WiFi is the only transport with no platform caveats at all.
Critical rule for non-mobile targets. AddShinyObdBluetoothLE() registers the BLE manager itself
only on iOS and Android. On Linux, Blazor WebAssembly, Windows and Apple desktop you must
generate a services.AddBluetoothLE() call as well, from the platform package the app references:
services.AddBluetoothLE(); // Shiny.BluetoothLE.Linux / .Blazor / .BluetoothLE
services.AddShinyObdBluetoothLE(); // order does not matter — DI resolves lazily
Never try to make Shiny.Obd.Ble do this for the user: Shiny.BluetoothLE.Linux and
Shiny.BluetoothLE.Blazor both ship net10.0 assemblies declaring
Shiny.AddBluetoothLE(IServiceCollection), so referencing both is a CS0121 ambiguity. Omitting the
call throws an ObdException naming the package to install.
Never generate two of AddShinyObdWifi() / AddShinyObdSerial() / AddShinyObdBluetoothLE() for a
fallback chain. TryAdd means the first one registered wins and the rest are silently ignored. For
a multi-transport fallback, construct the transports directly and try them in order.
WiFi Transport (Shiny.Obd.Wifi)
A raw TCP socket to an ELM327 WiFi adapter — OBDLink MX Wi-Fi, Veepeak WiFi, Vgate iCar, ESP8266/ ESP32 clones. Works on every platform identically, including iOS and Android. There is no platform package to add.
Note for users who ask about the OBDLink MX+: that model is Bluetooth (BLE + classic SPP), not
WiFi — generate Shiny.Obd.Ble for it. The WiFi model in that line is the OBDLink MX Wi-Fi.
WifiObdConfiguration
public class WifiObdConfiguration
{
public string? Host { get; set; } // null = discover
public int Port { get; set; } = 35000;
public WifiObdEndpoint[] EndpointCandidates { get; set; }
public bool AutoDetectEndpoint { get; set; } = true;
public bool IncludeGatewayCandidates { get; set; } = true;
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan ProbeTimeout { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan KeepAliveInterval { get; set; } = TimeSpan.FromSeconds(20); // Zero disables
public bool NoDelay { get; set; } = true;
public Action<Socket>? ConfigureSocket { get; set; }
}
public record WifiObdEndpoint(string Host, int Port); // ToString() => "host:port"
192.168.0.10:35000 is the OBDLink/ScanTool default and what most clones copied; 192.168.4.1 is
the stock ESP8266/ESP32 SoftAP address. A minority of clones use port 23.
WifiObdTransport
Constructors:
WifiObdTransport(WifiObdConfiguration config, ILogger<WifiObdTransport>? logger = null)WifiObdTransport(string host, int port = 35000, ILogger<WifiObdTransport>? logger = null)— setsAutoDetectEndpoint = falseWifiObdTransport(ObdDiscoveredDevice device, WifiObdConfiguration config, ILogger<WifiObdTransport>? logger = null)
ConnectedEndpoint (WifiObdEndpoint?) and DetectedIdentifier (string?) report what was reached.
Registration
services.AddShinyObdWifi(); // probe for the adapter
services.AddShinyObdWifi("192.168.0.10", 35000); // pin it, skip detection
services.AddShinyObdWifi(config => config.KeepAliveInterval = TimeSpan.Zero);
Rules to apply when generating WiFi code
- Never claim a TCP connect means the adapter is there. Anything listening accepts — a router on
192.168.0.1completes the handshake and then says nothing.AutoDetectEndpointvalidates with ATI and only accepts a>-terminated reply. Leave it on unless the user pins a host. - Joining the WiFi network is the app's job, not the transport's. On Android the adapter's
AP has no internet, so the OS keeps the default route on cellular and the socket connects to
nothing; generate
ConnectivityManager.BindProcessToNetwork(network)or bind the socket viaConfigureSocket. On iOS, addNSLocalNetworkUsageDescriptiontoInfo.plist— a denial is silent and looks like a dead adapter. - **Never write an auto-reconnect loop around
…(truncated)