iOSとOBD-IIで車両データを取得する

f:id:aptpod-tetsu:20211208151249j:plain aptpod Advent Calendar 2021 の 10日目を担当する、プロジェクト開発グループの尾澤です。

現在、自動車のOBD-IIから車両データを取得するiOSアプリを開発しています。

簡易的なデータ収集で事足りるケースであれば、OBD-IIアダプターと呼ばれる2000〜4000円程度のデバイスとスマートフォンだけでお手軽に環境を揃えることができます。

今回は簡単なiOSアプリを作りながら、LELinkというOBD-IIアダプターを利用した車両データ取得の過程を辿りたいと思います。細かい話はなるべく省き、全体像がざっくりと掴めるようにしたつもりです。どうぞ最後までお付き合いください。

接続する

OBD(On Board Diagnosis)とは、自動車の排気ガス低減を目的とし、排ガスの異常を検知するための仕組みです。当初、自動車メーカーごとに異なっていた規格等を共通化し、OBD-IIとなりました。異常の検知だけではなく、車速やエンジンの回転数など、様々なデータをリアルタイムで取得することもできます。

OBD-IIと外部機器を繋ぐためのデバイスがOBD-IIアダプターです。ELM327というチップを搭載したものが多いようです。接続方法としてBLE(Bluetooth Low Energy)タイプとWiFiタイプが存在しますが、本記事ではBLE接続タイプのLELinkという製品を利用します。

www.outdoor-apps.com

全体の構成は以下のようになります。

f:id:aptpod_tech-writer:20211208233452p:plain

データを取得する

車両データの取得は、アプリからOBD-IIアダプターに対しコマンドを送信してそのレスポンスを受信する、言わば対話形式で進みます。

コマンド

コマンドはたとえば以下のようなものです。

AT Z
AT FE
AT E0
AT H1
AT SP 0
010C

ATから始まるのがATコマンド、16進数で表されるのが一般的にOBDコマンドと呼ばれます。

ATコマンド

ELM327に対するコマンドです。主に通信周りやレスポンスの形式などの設定を行います。車種によって必要なコマンドが異なる場合があります。詳細はこちらをご参照ください。

OBDコマンド

ECU(Electronic Control Unit)に対するコマンドで、各種車両データを取得します。先頭の2桁をSID(Service ID)、次の2桁をPID(Parameter ID)と呼びます。詳細はWikipediaに良くまとめられています。

en.wikipedia.org

これによると、たとえば010Cはエンジン回転数(Engine speed)を取得するコマンドであることが分かります。

なお、

Each manufacturer may define additional services above #9 (e.g.: service 22 as defined by SAE J2190 for Ford/GM, service 21 for Toyota) for other information

という記述は注意が必要で、トヨタ車の場合は車種によってSID=01の代わりに21が使われます。

ATコマンドで初期設定を行った後にOBDコマンドでデータを取得する、というのがよくあるパターンだと思います。

BLE通信

LightBlueというアプリを利用すると、そのデバイスに用意されているServiceとCharacteristicの一覧を確認することができます(BLE通信自体の解説は割愛します)。

LightBlue®

LightBlue®

  • Punch Through
  • ユーティリティ
  • 無料
apps.apple.com

このアプリとLELinkを繋いでみると、幸い用意されているServiceは1つ(UUID=0xFFE0)、その中のCharacteristicも1つ(UUID=0xFFE1)だけで、かつそのCharacteristicがWriteとNotify属性を併せ持つことが分かるので、これがコマンド送信(Write)とレスポンス受信(Notify)を兼ねたものであると推測できます。

実装

ではここまでを踏まえて、いったんサンプルコードを示します。おおよそ以下のような処理です。

  1. BLE上で対象のOBD-IIアダプター(ペリフェラル)を探す
  2. OBD-IIアダプターと接続
  3. コマンドを送信
  4. レスポンスを受信
定義

LELinkはWriteもNotifyも同じCharacteristicですが、異なる場合でも対応できるように定義は敢えて分けています。

/// ServiceのUUID
private let serviceUUID = CBUUID(string: "FFE0")
/// 通知用CharacteristicのUUID
private let notifyCharacteristicUUID = CBUUID(string: "FFE1")
/// 書き込み用CharacteristicのUUID
private let writeCharacteristicUUID = CBUUID(string: "FFE1")

private var centralManager: CBCentralManager?
private var discoveredPeripheral: CBPeripheral?
初期化

CBCentralManagerを生成します。

centralManager = CBCentralManager(delegate: self, queue: nil)
CBCentralManagerDelegate

次にCBCentralManagerDelegateを実装します。まずはCBCentralManagerの状態を監視し、利用可能であればスキャンを開始し、指定のServiceを持つペリフェラルを探索します。

public func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .poweredOn:
        centralManager?.scanForPeripherals(withServices: [serviceUUID], options: nil)
    default:
        break
    }
}

ペリフェラルが見つかったら接続開始します。

public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {        
    discoveredPeripheral = peripheral
    centralManager?.stopScan()
    centralManager?.connect(peripheral, options: nil)
}

接続に成功したら、当該ペリフェラルが持つServiceを探します。以降はCBPeripheralDelegateに処理が移ります。

public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {    
    peripheral.delegate = self
    peripheral.discoverServices([serviceUUID])
}
CBPeripheralDelegate

CBPeripheralDelegateを実装します。Serviceが見つかったら、そのService内のCharacteristicを探します。

public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    guard let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else {
        return
    }
    
    peripheral.discoverCharacteristics(nil, for: service)
}

Characteristicが見つかったら、その中から指定のUUIDのものを探します。通知用に対しては更新を受け取るように設定します。

public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {  
    // 通知用characteristic
    if let characteristic = service.characteristics?.first(where: { $0.uuid == notifyCharacteristicUUID }) {
        self.notifyCharacteristic = characteristic
        discoveredPeripheral?.setNotifyValue(true, for: characteristic)
    }

    // 書き込み用characteristic
    if let characteristic = service.characteristics?.first(where: { $0.uuid == writeCharacteristicUUID }) {
        self.writeCharacteristic = characteristic
    }
}

前後しますが、後述のコマンド送信のレスポンスをここで受信します。受け取ったData型をString型に変換します。

public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    guard let value = characteristic.value, let string = String(data: value, encoding: .utf8), !string.isEmpty else {
        return
    }
    
    print(string)
}
コマンド送信

エンジン回転数を取得する010Cを送信します。終端文字として\rを付加してData型に変換し、書き込み用Characteristicに対してこれを書き込みます。

guard let data = "010C\r".data(using: .utf8) else {
    return
}

guard let characteristic = writeCharacteristic else {
    return
}

discoveredPeripheral?.writeValue(data, for: characteristic, type: .withResponse)

データを解析する

さてレスポンスを受け取ることができたら、いよいよその解析です。010Cを送信した結果、たとえば

7E8 04 41 0C 10 A6

のようなレスポンスが返却されたとします。この意味については下記のサイトに分かりやすく整理された図がありますのでそちらを参照ください。

www.csselectronics.com

結論だけ書くと最後の10 A6が値の部分に相当しますが、これではまだ意味を成しません。まるで謎解きのようですが、これをさらに物理値変換式にかける必要があります。先述のWikipediaによるとPID=0Cの変換式は、

 \displaystyle \frac{256A + B}{4}

また単位はrpmであることが分かります。ここで10 A6をそれぞれ10進数に変換した16 166を上記のAとBに代入します。

 \displaystyle \frac{256 \times 16 + 166}{4}=1065

ということで、めでたくエンジン回転数1065rpmというデータを得ることができました!

まとめ

iOSアプリでOBD-IIから車両データを取得する流れを駆け足で説明しました。今回のサンプルコードは1つのコマンドを投げ、そのレスポンスを受け取るところまでですが、実際にはこれをループさせ、コマンドを順番に投げる仕組みがあると便利です。

少しでもご興味頂けたら幸いです。

以上、お読み頂きましてありがとうございました。