# Shiny Obd

> Generate code using Shiny.Obd, an OBD-II vehicle communication library for .NET with command-object pattern, adapter auto-detection, and BLE + WiFi (TCP) + serial (USB/UART) transports

- Skill: `shinyorg/shiny-obd` (Agent Skill)
- Install (CLI): `npx skillmds@latest add shinyorg/shiny-obd`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shinyorg/shiny-obd/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: shinyorg (https://skillmd.com/u/shinyorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/shinyorg/shiny-obd

---


# 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.0` throughout

## Core Types

### IObdCommand<T> — Command interface

Every OBD command implements this. `T` is the parsed result type.

```csharp
public interface IObdCommand<T>
{
    string RawCommand { get; }
    T Parse(byte[] data);
}
```

### ObdCommand<T> — Base class for standard Mode/PID commands

Validates mode+PID response header, strips it, and delegates to `ParseData`.

```csharp
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

```csharp
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

```csharp
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

```csharp
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

```csharp
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 ATI
- `ObdConnection(IObdTransport transport, IObdAdapterProfile profile)` — uses explicit profile, skips detection

Properties:
- `DetectedAdapter` — `ObdAdapterInfo?` with `RawIdentifier` (string) and `Type` (ObdAdapterType enum: Unknown, Elm327, ObdLink). Null when explicit profile used.
- `Protocol` — `string?`, settable before `Connect`. The ELM protocol number to pin with `ATSP` instead 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 by `Connect` and by `RefreshNegotiatedProtocol()`.

Methods:
- `RefreshNegotiatedProtocol(CancellationToken)` — re-reads `ATDPN` and updates `NegotiatedProtocol`.

**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:

```csharp
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 01` and `0:490201`)
- Error detection: "NO DATA", "UNABLE TO CONNECT", "BUS INIT: ...ERROR", "?"
- Strips "SEARCHING..." and "BUS INIT" prefixes

### IObdAdapterProfile — Adapter initialization

```csharp
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, then `ATSP{protocol}` or `ATSP0`
- `ObdLinkAdapterProfile(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`, PID `0x16` is bank 1, sensor 3.
- Under `0x1D`, PID `0x16` is 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.

```csharp
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`:

```csharp
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:

```csharp
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:

1. **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.Value` already applies it.
2. **Never treat a raw value as unsigned by default.** Identifiers `0x80` and above are signed; reading
   one unsigned turns a small negative into ~65,535 and a passing test into a dramatic failure.
3. `Value`, `Unit`, `Passed` and `BandPosition` are **null** when the identifier is outside the standard
   table. Surface that as unknown; `RawValue`/`RawMinimum`/`RawMaximum` are still there.
4. `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.
5. `Monitor` is null for MIDs above `0xDF` (manufacturer-defined). Do not invent a name.
6. Mode 06 is CAN-only here. On a pre-CAN vehicle it throws `ObdException` naming 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.

```csharp
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.

```csharp
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

```csharp
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.

```csharp
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:

- `Monitors` contains **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.Complete` has 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.GasolineParticulateFilter` is 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 — its `MilOn` and
  `DtcCount` are 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.

```csharp
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:

```csharp
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.

```csharp
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"` or `0` — these values reach
  users and AI prompts, where a placeholder reads as a fact about the car.
- **`IVinDecoder` never 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` / `Normalize` are the pure pre-check (17 chars, no I/O/Q). `Decode` applies
  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), and `IsCheckDigitValid(vin)` compares it to what is there. It is
  deliberately outside `IsPlausible` because 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.

  ```csharp
  // 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.Describe` off 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

```csharp
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 adapter
- `BleObdTransport(IPeripheral peripheral, BleObdConfiguration config)` — uses pre-discovered peripheral
- `BleObdTransport(ObdDiscoveredDevice device, BleObdConfiguration config)` — uses device from scanner

Uses Shiny.BluetoothLE v4 APIs:
- `ConnectAsync` for task-based connection
- `NotifyCharacteristic` for RX notifications
- `WriteCharacteristicAsync` for 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:

```csharp
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.

```csharp
// 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

```csharp
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:

```csharp
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)

```csharp
// 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:

```csharp
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

```csharp
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)` — sets `AutoDetectEndpoint = false`
- `WifiObdTransport(ObdDiscoveredDevice device, WifiObdConfiguration config, ILogger<WifiObdTransport>? logger = null)`

`ConnectedEndpoint` (`WifiObdEndpoint?`) and `DetectedIdentifier` (`string?`) report what was reached.

### Registration

```csharp
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

1. **Never claim a TCP connect means the adapter is there.** Anything listening accepts — a router on
   `192.168.0.1` completes the handshake and then says nothing. `AutoDetectEndpoint` validates with
   ATI and only accepts a `>`-terminated reply. Leave it on unless the user pins a host.
2. **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 via
   `ConfigureSocket`. On **iOS**, add `NSLocalNetworkUsageDescription` to `Info.plist` — a denial is
   silent and looks like a dead adapter.
3. **Never write an auto-reconnect loop around

…(truncated)
