iPhone
BLE
OBD2
Swift
RxSwift

OBD2とBLEで車速計アプリを作ろう

ドワンゴAdventCalendar 18日目。

若者の車離れが叫ばれて久しい昨今ですが、時代の流れに逆らって昨年車を買いました。もしかしたらもう若者じゃないのかもしれません。
LRG_DSC00136.jpg
旧型コペンです。丸っこくて屋根が変形します。かわいいですね。
最近はこいつにキャンプ道具積んであちこち行っております。助手席を潰せばオープン状態でもキャンプにいけます。助手席は荷物置き場であって人を載せる場所じゃありません。

さて最近は自動運転とかMaaSとかで大いに賑わってますし、車関連の技術が面白そうだなーと思っているのですが、そういう最新テクノロジーの詰まった車は高いので今回はコペンで遊んでみようと思います。

OBD2とは

ここ最近の車には自己診断用にOBD2(On Board Diagnosis 2nd generation)というポートが付いており、ここから車速やガソリン残量といった車に関する様々なデータを取得することができます。OBD2ポートに経由して車体情報にアクセスする場合、ELM327というチップを搭載した端末を利用して通信を行うのが一般的なようで、AmazonでもこのELM327互換のデバイスが様々売られています。USB接続やWiFi接続、Bluetooth接続ができるものまで比較的お安い値段で入手することができます。

今回はiOSアプリから車体情報にアクセスしたいので、WiFiもしくはBluetooth接続できるデバイスを選択する必要があります。とはいえ、OBD2ポートは基本的に常時電源が流れているため、エンジンを切った状態で電力消費の高いデバイスをOBD2ポートに接続したままでいると、バッテリーあがりの原因にもなりかねます。そこで今回は低消費電力で利用できるBluetoothLowEnergy(BLE)に対応したこちらのデバイスを利用して、アプリを作ってみることにしました。(昨年くらいに購入したので在庫切れになってしまっているようです(´◔‸◔`))

デバイスとの接続方法を調べる

さて、購入したこちらのデバイスですが、説明書などは何も入っておらず、どうやって通信すればいいのかさっぱりわかりません。まずはこのデバイスとどうすれば通信できるかを調べていく必要があります。そこで近くにあるBLEデバイスを検索・通信することができるLightBlue Explorerというアプリを利用してデバイスとの通信方法を調べていくことにします。

LightBlueアプリを起動すると周辺のBLEデバイスが表示されます。この状態でコペンのOBD2ポートにデバイスを接続してエンジンをかけると、OBDIIというデバイスがアプリ上に表示されます。
IMG_3004.png
このOBDIIを選択すると、このデバイスが提供するCharacteristicの一覧が表示されます。
IMG_3005.png
このなかの0xFFF0サービス内にある0xFFF10xFFF2がそれぞれ、OBD2ポートの入力と出力に相当しそうです。試しに0xFFF1の通知を監視しつつ、0xFFF2からAT RVという文字列を送信してみます。すると0xFFF1から通知が飛び、13.7Vのような文字列を取得することができました。

AT RVとはELM327のコマンドで、バッテリー電圧を取得することができるコマンドです。この動きから、0xFFF2に対してELM327のコマンドを書き込めば0xFFF1からその結果を受け取ることができることがわかりました。アプリでは最初にこの0xFFF10xFFF2のCharacteristicに接続し、車速を取得するコマンドを定期的に送って結果を表示すれば車速計を実装することができそうです。

コペンと通信してみる

BLE経由での通信方法はわかったので、今度はELM327でコペンから車速を取得する方法を調べていきます。このあたりは先人の知識を拝借していきます。

割と古い車なので、ELM327で通信するためにいくつか初期化準備が必要なようです。完全に理解してないですが、上記の記事をそのまま参考にさせてもらいます。

初期化が完了すると21 0D 01で車速、21 0C 01でエンジン回転数、21 0B 01でブースト圧をそれぞれ取得できるようです。ですので初期化完了後、これらのコマンドを定期的に実行して値を取得すれば目的の動作を達成できそうです。

実装してみよう

必要な情報が揃ったので実装していきます。今回はRxを使うのでBT通信にもRxBluetoothKitを利用しました。

まずアプリからBLEデバイスへ接続する処理を実装していきます。CentralManagerを生成し、Managerの状態を監視して利用可能(.poweredOn)であれば、0xFFF0のサービスを持つPeripheralを検索します。対象のサービスを持つPeripheralが見つかったら、establishConnectionで接続し、接続が完了したらdiscoverServicesでPeripheral内の対象サービスを検索します。

let ServiceUUID = CBUUID(string: "0xFFF0")
let centralManager = CentralManager(queue: .main, options: [:])

let service = centralManager.observeState()
    .filter { $0 == .poweredOn }
    .take(1)
    .flatMap { [unowned self] _ -> Observable<ScannedPeripheral> in
        self.centralManager.scanForPeripherals(withServices: [ServiceUUID]).take(1)
    }
    .flatMap { $0.peripheral.establishConnection() }
    .flatMap { $0.discoverServices([ServiceUUID]) }
    .flatMap { Observable.from($0) }
    .share(replay: 1)

続いて、サービス内の書き込み用のCharacteristicと通知用のCharacteristicを取得します。先程取得したserviceからdiscoverCharacteristicsを利用してそれぞれのUUIDから該当のCharacteristicsを検索して取得します。

let NotifyCharacteristicUUID = CBUUID(string: "0xFFF1")
let WriteCharacteristicUUID = CBUUID(string: "0xFFF2")

let notifyCharacteristic = service
    .flatMapLatest { $0.discoverCharacteristics([WriteCharacteristicUUID]) }
    .flatMap { Observable.from($0) }
    .share(replay: 1)

let writeCharacteristic = service
    .flatMapLatest { $0.discoverCharacteristics([WriteCharacteristicUUID]) }
    .flatMap { Observable.from($0) }
    .share(replay: 1)

let characteristicConnected = Observable
    .zip(notifyCharacteristic, writeCharacteristic).map { _ in }

Characteristicの取得ができたら、まずはELM327のレスポンス取得処理を実装します。notifyCharacteristicobserveValueUpdateAndSetNotificationで値に変更があった場合と通知が届いた場合にストリームを取得するようにし、characteristic.valueから文字列を生成します。

また、ELM327ではコマンドを送信できるようになったタイミングでプロンプトを出力するので、プロンプトが出力されたことを検出するためのストリームも生成しておきます。

let messageReceived = notifyCharacteristic
    .flatMapLatest { $0.observeValueUpdateAndSetNotification() }
    .map { (characteristic) -> String? in
        guard let data = characteristic.value else { return nil }
        return String(data: data, encoding: .utf8)
    }
    .unwrap()
    .share(replay: 1)

let promptDetected = messageReceived
    .filter { $0.hasSuffix(">") }
    .map { _ in }

次にコマンドを送信する仕組みを実装します。commandQueueに逐次実行したいコマンドを流していくようにしておき、プロンプトを検出するとObservable.zipを利用して、ひとつずつcurrentCommandに流すようにします。そしてcurrentCommandにnil以外が設定された場合は、writeCharacteristicに対してコマンドを書き込むようにします。

let commandQueue = PublishSubject<Command>()
let currentCommand = BehaviorRelay<Command?>(value: nil)

Observable
    .zip(promptDetected.startWith(()), commandQueue)
    .map { $1 }
    .bind(to: currentCommand)
    .disposed(by: disposeBag)

currentCommand.asObservable()
    .unwrap()
    .withLatestFrom(writeCharacteristic) { ($0, $1) }
    .flatMap { (command, characteristic) -> Observable<Characteristic> in
        let data = (command + "\r").data(using: .utf8)!
        return characteristic.writeValue(data, type: .withoutResponse).asObservable()
    }
    .subscribe()
    .disposed(by: disposeBag)

最後にCharacteristicの準備が整った段階で初期化コマンドを送信するようにしておきます。これで車速を取得する準備が整いました。

let initializeCommamnds: [String] = [
    "AT RV",
    "ATI",
    "AT PC",
    "AT D",
    "AT E0",
    "AT SP 5",
    "AT IB 10",
    "AT KW0",
    "AT ST 80",
    "AT IIA 10",
    "AT SH 81 10 F0",
    "21 00 01",
]

characteristicConnected
    .flatMap { Observable.from(initializeCommamnds) }
    .bind(to: commandQueue)
    .disposed(by: disposeBag)

あとは一定間隔おきに各値を取得する前のコマンドを送信し、その結果をもとに値を更新していきます。

enum Command: String, CaseIterable {
    case .getSpeed = "21 0D 01"
    case .getEngineRPM = "21 0C 01"
    case .getBoost = "21 0B 01"
}

let speed = BehaviorRelay<Double>(value: 0)
let rpm   = BehaviorRelay<Double>(value: 0)
let boost = BehaviorRelay<Double>(value: 0)

Observable<Int>.interval(1.0, scheduler: MainScheduler.asyncInstance)
    .flatMap { _ in Observable.from(Command.allCases.map { $0.rawValue }) }
    .bind(to: commandQueue)
    .disposed(by: disposeBag)

messageReceived
    .withLatestFrom(currentCommand) { ($0, $1) }
    .subscribe { (message, currentCommand) in
        guard command = Command(rawValue: currentCommand else { return }
        let value = Double(parse(message))
        switch command {
            case .getSpeed: speed.accept(value)
            case .getEngineRPM: rpm.accept(value * 0.25)
            case .getBoost: boost.accept(boost / 100.0)
        }
    }
    .disposed(by: disposeBag)

// 雑
func parse(_ s: String) -> Int {
    let value = s.trimmingCharacters(in: .whitespacesAndNewlines)
        .filter { $0 != " " }.dropFirst(4)
    return Int(value, radix: 16)!
}

そんな感じで実装したアプリがこちらになります。リアルタイム性はイマイチですがちゃんと値がとれていますね。
sample.gif
ソースコードはgithubに置いてあります。

https://github.com/saiten/OBD2Meter

まとめ

というわけで、OBD2を経由して車体情報にアクセスするアプリを作ってみました。OBD2を使うと車もガジェット感覚で扱えるので楽しいですね。(あんまり危険なことはできないと思いますが)

CarPlayでもう少し自由にアプリを動かせたらこういった車体情報を表示するアプリを作りたいところですが、まだまだ自由にアプリ動かすのは厳しそうです。来年に期待したいところ。