2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AndroidスマホとOBD2アダプターを使って車の内部情報を取得するのに「obd2-lib」を活用

Last updated at Posted at 2022-12-07

はじめに

bluetooth対応のOBD2アダプターを車のOBD(故障診断)コネクターと接続し、車の内部情報を取得するAndroidスマホアプリを作りたいと思います。類似のアプリは多数ありますが、自分なりに確認したり点や、改造したいところがあったので、自作を考えました。

OBD2アダプターの多くは「ELM327」というチップを使っているようで、車の内部情報を取得するにはOBD2アダプターに、「ELM327 datasheet」にあるような手順でATコマンドを送信し、プロトコルの設定や車側の対応の有無のチェックし、対応している情報をリクエスト、データ受信、物理的な値に変換すればよさそうです。

OBD2に関するライブラリーも多くあるようですが、google検索でたまたま見つけた「obd2-lib」(アンドロイド オープンソースプロジェクト?)で公開されているソースコードを活用することで、主な機能を自作せず、車の内部情報を取得できました。

検証環境

この記事の内容は、以下の環境で検証しました。

  • Windows 10 Home
  • Android Studio Chipmunk | 2021.2.1 Patch 1
  • targetSdk 32
  • 実機確認 Sony SOG04 Android 12,API 31

obd2-libの内容

obd2-libは以下の図のような構成になっています。
obd2-lib.PNG

commands ディレクトリ

commandsディレクトリ内のファイルは、OBD2ツールで取得可能な自動車の内部データの定義(パラメータID、データサイズ、変換式等)の情報が、取得可能なデータごとに記載されています。例えば、パラメータID(以下、"PID"と表記) 05 の Engine coolant temperature(エンジン冷却水温) はEngineCoolantTemperature.javaに入っています。

EngineCoolantTemperature.java
package com.android.car.obd2.commands;

import com.android.car.obd2.IntegerArrayStream;
import com.android.car.obd2.Obd2Command;
import java.util.Optional;

public class EngineCoolantTemperature implements Obd2Command.OutputSemanticHandler<Integer> {
    @Override
    public int getPid() {
        return 0x05;
    }

    @Override
    public Optional<Integer> consume(IntegerArrayStream data) {
        return data.hasAtLeast(
                1,
                theData -> Optional.of(theData.consume() - 40),
                theData -> Optional.<Integer>empty());
    }
}

「OBD-II PIDs」によると、

PIDs(hex) Data bytes Formula
05 1 $A-40$

とあるので、data.hasAtLeast(Data bytes, Formula)の、theData.consume() - 40がFormula(変換式)に相当する部分ですね。このIntegerArrayStreamは1~4文字の文字列をint型配列として受け取り、値があればconsume()するたびに一文字ずつ取り出し、となっているのでこのような形で実現しているようです。

他にも、取りたいデータがサンプルコードの中になければOBD-II PIDsの定義に従って用意すればいいようです。ただし、例えばAmbientAirTemperature(=外気温)はパラメータIDが16進で 46、変換式は $A-40$ なのに、A*100/256になっていましたので、EngineCoolantTemperature.javaと同じにした方がいいと思います。他のPIDも使用する前に合っているかどうか、確認してからにした方がよさそうです。

AmbientAirTemperature.java
package com.android.car.obd2.commands;

import com.android.car.obd2.IntegerArrayStream;
import com.android.car.obd2.Obd2Command;
import java.util.Optional;

public class AmbientAirTemperature implements Obd2Command.OutputSemanticHandler<Float> {
    @Override
    public int getPid() {
        return 0x46;
    }

    @Override
    public Optional<Float> consume(IntegerArrayStream data) {
        return data.hasAtLeast(
                1,
                theData -> Optional.of(theData.consume() - 40),
            //  theData -> Optional.of(theData.consume() * (100.0f / 255.0f)), <- 変更前
                theData -> Optional.<Float>empty());
    }
}

IntegerArrayStream.java

IntegerArrayStream.javapackageを今回のアプリに合わせて変更する以外は、そのまま使えます。

メソッド 概要
int consume() int型配列からひとつづつ値を取り出す
int residualLength() 配列(データ数)の残りの数を返す
boolean isEmpty() 残りの配列(データ数)がゼロかどうか
boolean hasAtLeast(int n) 残りの配列(データ数)がnより多いかどうか
<T> hasAtLeast(int n, Function<IntegerArrayStream, T> ifTrue, Function<IntegerArrayStream, T> ifFalse) 配列の残りがnの場合はifTrue 関数を、違う場合はifFalse関数を適用
boolean expect(int... values) いくつかの文字コードが決められた順番で格納されているかどうか

hasAtLeast(int n, Function<IntegerArrayStream, T> ifTrue, Function<IntegerArrayStream, T> ifFalse)

    public <T> T hasAtLeast(
            int n,
            Function<IntegerArrayStream, T> ifTrue,
            Function<IntegerArrayStream, T> ifFalse) {
        if (hasAtLeast(n)) {
            return ifTrue.apply(this);
        } else {
            if (ifFalse != null) {
                return ifFalse.apply(this);
            } else {
                return null;
            }
        }
    }

というロジックになっています。

Obd2Command.java

Obd2Command.javaもロジック部分はそのまま持ってきました。
ここにはcommandsディレクトリの情報を実際にどう処理するか記述されています。戻り値がint型(Integer型)の情報は、SUPPORTED_INTEGER_COMMANDSというマップに、float型(Float型)の情報はSUPPORTED_FLOAT_COMMANDSというマップに、PIDをキーに格納されます(addSupportedIntegerCommandsaddSupportedFloatCommands)。取得した車両情報を追加したい場合は、該当するPID、変換式を記述したクラスをcommandsに追加し、ここに追記することで追加可能ですし、不要になればここで削除すればよさそうです。

さらに、getSupportedIntegerCommandsgetSupportedFloatCommandsメソッドでこれらのマップのキーセットが取得できます。また、getIntegerCommand(int pid)getFloatCommand(int pid)でPIDを指定して各クラスを選択することも出来ます。

Obd2Command.java
...
    private static final HashMap<Integer, OutputSemanticHandler<Integer>>
            SUPPORTED_INTEGER_COMMANDS = new HashMap<>();
    private static final HashMap<Integer, OutputSemanticHandler<Float>> SUPPORTED_FLOAT_COMMANDS =
            new HashMap<>();

    private static void addSupportedIntegerCommands(
            OutputSemanticHandler<Integer>... integerOutputSemanticHandlers) {
        for (OutputSemanticHandler<Integer> integerOutputSemanticHandler :
                integerOutputSemanticHandlers) {
            SUPPORTED_INTEGER_COMMANDS.put(
                    integerOutputSemanticHandler.getPid(), integerOutputSemanticHandler);
        }
    }

    private static void addSupportedFloatCommands(
            OutputSemanticHandler<Float>... floatOutputSemanticHandlers) {
        for (OutputSemanticHandler<Float> floatOutputSemanticHandler :
                floatOutputSemanticHandlers) {
            SUPPORTED_FLOAT_COMMANDS.put(
                    floatOutputSemanticHandler.getPid(), floatOutputSemanticHandler);
        }
    }

    public static Set<Integer> getSupportedIntegerCommands() {
        return SUPPORTED_INTEGER_COMMANDS.keySet();
    }

    public static Set<Integer> getSupportedFloatCommands() {
        return SUPPORTED_FLOAT_COMMANDS.keySet();
    }

    public static OutputSemanticHandler<Integer> getIntegerCommand(int pid) {
        return SUPPORTED_INTEGER_COMMANDS.get(pid);
    }

    public static OutputSemanticHandler<Float> getFloatCommand(int pid) {
        return SUPPORTED_FLOAT_COMMANDS.get(pid);
    }

    static {
        addSupportedFloatCommands(
                new AmbientAirTemperature(),
                new CalculatedEngineLoad(),
                new FuelTankLevel(),
                new Bank2ShortTermFuelTrimCommand(),
                new Bank2LongTermFuelTrimCommand(),
                new Bank1LongTermFuelTrimCommand(),
                new Bank1ShortTermFuelTrimCommand(),
                new ThrottlePosition());
        addSupportedIntegerCommands(
                new EngineOilTemperature(),
                new EngineCoolantTemperature(),
                new FuelGaugePressure(),
                new FuelSystemStatus(),
                new RPM(),
                new EngineRuntime(),
                new Speed());
    }

    ...

次に、Obd2Commandint型modeOutputSemanticHandler<ValueType>が引数になっていて、modeLIVE_FRAMEFREEZE_FRAMEですが、今回はLIVE_FRAMEを使います。OutputSemanticHandlergetPid()consumeというインターフェースを持つクラスで、前述のcommandsディレクトリーにある、それぞれのPIDごとのクラスがこのインターフェースを実装(implements)しているので、あとでPIDごとに引き当ててListにして使います。

Obd2Command.java

    ...

    protected final int mMode;
    protected final OutputSemanticHandler<ValueType> mSemanticHandler;

    Obd2Command(int mode, OutputSemanticHandler<ValueType> semanticHandler) {
        mMode = mode;
        mSemanticHandler = Objects.requireNonNull(semanticHandler);
    }

    public abstract Optional<ValueType> run(Obd2Connection connection) throws Exception;

    public int getPid() {
        return mSemanticHandler.getPid();
    }

    public static final <T> LiveFrameCommand<T> getLiveFrameCommand(OutputSemanticHandler handler) {
        return new LiveFrameCommand<>(handler);
    }

    ...

その後に、Obd2Commandクラスを継承するLiveFrameCommandFreezeFrameCommandが定義されています。今回は、走行中のデータを取りたいので、LiveFrameCommandを使います。FreezeFrameデータは、故障判定時に原因調査が出来ると思われるデータを保存する(Freeze)時に有効ですが、今回は使いません。

runメソッドはまず、String型の文字列commandを作っていますが、これがELM327を通して車両のデータを要求するコマンドになっています。「ELM327 datasheet」に詳細はありますが、例えばPID 05(Hex)の今の値を要求するコマンドは、01 05です。

    String command = String.format("%02X%02X", mMode, mSemanticHandler.getPid() 

の部分で文字列として作成していて、コンストラクタでsuper(LIVE_FRAME, semanticHandler)が呼ばれてLIVE_FRAME = 1なのでmMode = 1となり、該当するgetPid()メソッドで今回の例だと05となって01 05が実現されています。0105の間のスペース有無はどちらでもいいようです。ここで、01は"mode 01"現在の値を取得(show current data)という意味で、01 05はPID 05の現在の値を要求するという意味になり、01の要求の回答は先頭が41になって返送されます。

41LiveFrameCommandの直後で

    private static final int RESPONSE_MARKER = 0x41;

と定義されていて、mode 01 の応答(01 + 40 = 41)を意味するそうです。

PID 05の回答は41 05 7Bになり、最後の7Bが取得できたデータ(現在の値)になります。なので、IntegerArrayStream streamstream.expectで RESPONSE_MARKER(= 41) と、該当するPID(今回は05)が続けて返って来たと判定できれば、mSemanticHandler.consume(stream)でその次に送られてきた値を取り出し、それぞれのPIDの変換式にしたがって変換した値が取得できる、という仕組みになっています。

Obd2Command.java
    ...

    /**
     * An OBD2 command that returns live frame data.
     *
     * @param <ValueType> The Java type that represents the command's result type.
     */
    public static class LiveFrameCommand<ValueType> extends Obd2Command<ValueType> {
        private static final int RESPONSE_MARKER = 0x41;

        LiveFrameCommand(OutputSemanticHandler<ValueType> semanticHandler) {
            super(LIVE_FRAME, semanticHandler);
        }

        public Optional<ValueType> run(Obd2Connection connection)
                throws IOException, InterruptedException {
            String command = String.format("%02X%02X", mMode, mSemanticHandler.getPid());
            int[] data = connection.run(command);
            IntegerArrayStream stream = new IntegerArrayStream(data);
            if (stream.expect(RESPONSE_MARKER, mSemanticHandler.getPid())) {
                return mSemanticHandler.consume(stream);
            }
            return Optional.empty();
        }
        ...

Obd2Connection.java

UnderlyingTransport インターフェース

ELM327の設定や、コマンドの送受信、車両からの応答の解析を実際に処理する部分は、Obd2Connection.javaにまとめられています。最初に、InputStreamOutputStreamのインターフェースの定義があります。これを実際にBluetooth接続するクラス(後述のBluetoothConnectionクラス)と関連付けて使います。また、オリジナルには再接続の設定もありますが、接続が切れた時の再接続はどう処理するのが妥当なのか、今は考えが及んでいませんので、実体の部分も合わせてコメントアウトしておきます。

ELM327設定用コマンド

UnderlyingTransport インターフェースのあとに

Obd2Connection.java
    private static final String[] initCommands =
            new String[] {"ATD", "ATZ", "AT E0", "AT L0", "AT S0", "AT H0", "AT SP 0"};

があります。この、ATDから始まる文字は、ELM327の設定をするためのコマンド(ATコマンド)で、「ELM327 datasheet」から抜粋すると以下のようになります。

コマンド 機能
ATD 初期化(set all to Defaults)
ATZ 全リセット(reset all)
AT E0 エコーオフ(Echo off)。 E1ならエコーオン、要求コマンドが応答に先立ちエコーとして送信される
AT L0 改行コードオフ(Linefeeds off)。 L1なら改行コードオン、送信文字列に改行コードが追加される
AT S0 スペース挿入オフ(printing of Spaces off)。 S1ならECUの応答にスペースが挿入される
AT H0 ヘッダーオフ(Headers off)。 H1なら車両からのヘッダー情報が追加されるようですが、通常はオフでいいようです
AT SP 0 プロトコル0(Automatic)を選択(set protocol to 0)。 プロトコルは0~9,A,B,Cから選択

最後のプロトコルは、車によっては自動(Automatic)では応答が来ない場合もあるので、上手く情報が出てこない場合はこれを変更して試すことになると思います。そして、コンストラクタが呼び出されたときに、runInitCommandsメソッドが呼ばれて、このコマンドが順番に送信されます。

コマンドの送信と応答の受信処理

runメソッドとrunImplで、Obd2Command.javaLiveFrameCommandでPIDごとに作ったコマンドを送信し、応答を処理する機能が実装されています。LiveFrameCommand.run(Obd2Connection connection)で、connection.run(command)とすると、この部分が実行される構成になっています。なかなか複雑ですね...

runメソッドからrunImplにコマンドを渡し、さらにOutputStreamに書き込んだところでInputStreamreadメソッドで応答を待ちます。このOutputStreammConnection.getOutputStream()なので、その先はBluetoothConnectionクラスのgetOutputStream()で、さらにはBluetoothSocketmSocket.getOutputStream()ですね。これも先が長い...

読み出した文字がプロンプト(>)であれば読み込みを終了、\r\n' ''\t'.は無視してStringBuilderに詰め込んで応答を返します。出来た文字列をさらに加工し、スペースを除いたり、今回は該当ないですが、OBD2のプロトコル的には複数のPIDをまとめて要求でき、その際は応答もまとまって返ってくるので、それを分解(unpackLongFrame)したりし、残った文字をさらに調べます。

ELM327を通じて返ってくる車の応答には、「SEARCHING」、「ERROR」、「BUS INIT」、「BUSINIT」、「BUS ERROR」、「BUSERROR」、「STOPPED」、「?」、「NODATA」、「UNABLETOCONNECT」、「CANERROR」が返ってくることがあります。「UNABLETOCONNECT」、「CANERROR」の場合は、IOExceptionを出すようになっていますが、それ以外は基本は無視ですね。上記のATコマンドがうまく成功した場合は「OK」が返ってきますが、処理としては特に何もしません。残った文字列を、さらに2桁ごとの16進文字として10進にし、int型配列にtoHexValuesメソッドで入れて呼び出し元に返します。

各PIDの対応(サポート)の有無調査

ATコマンドでELM327の設定が出来、クルマからの応答があることが確認できると、次は、欲しいPIDコードの情報がクルマ側で対応(サポート)されているかを調べます。車によって対応しているPIDが結構違うので、実際のデータを要求する前に、そのPIDの情報が返ってくる(サポートされている)のか、調べる必要があります。

コマンド01 00を送ると、PID 01から20までのコードが対応されているか、ビットごとに対応されてる場合は1が、されていない場合は0でセットされます。

「OBD-II PIDs」の例で説明すると、01 00の応答がBE1FA813の場合は、2進にすると
1011 1110 0001 1111 1010 1000 0001 0011
となるので、PID 01, 03, 04, 05, 06, 07, 0C, 0D, 0E, 0F, 10, 11, 13, 15, 1C, 1F と 20 がサポートされています、となります(最上位ビットからPID 01、02、... 最後が20という割付になっています)。

PID 20 がサポートされている、ということは、PIDs supported [21 - 40]ということで、PID 21から40も存在する、という意味になります。これは次に01 20を送ると応答が返ってくることを意味していて、この場合の最下位ビットはPID 40で、これが1なら、さらにPIDs supported [41 - 60]ということで、「OBD-II PIDs」では PID C0まで、192個のPIDが定義されています。

オリジナルの「obd2-lib」getSupportedPIDsメソッドでは、サポート有無を調べるコマンドを、"0100", "0120", "0140", "0160"と用意し、順番にforループで送信、それぞれのビットを調べ、サポートあるPID コードをマップ(リスト化)して返しています。が、試したところ例えばPID 20が対応していない車両に01 20を送信しても応答が返ってこず、処理が止まってしまいました。そこで、今回のコマンドに対する応答の最下位ビットが1なら次のコマンドも送信、0ならbreakという処理を追加しています。

Obd2Connection.java
    ...

    public Set<Integer> getSupportedPIDs() throws IOException, InterruptedException {
        Set<Integer> result = new HashSet<>();
        String[] pids = new String[] {"0100", "0120", "0140", "0160"};
        int basePid = 1;
        for (String pid : pids) {
            int[] responseData = run(pid);
            if (responseData.length >= 6) {
                byte byte0 = (byte) (responseData[2] & 0xFF);
                byte byte1 = (byte) (responseData[3] & 0xFF);
                byte byte2 = (byte) (responseData[4] & 0xFF);
                byte byte3 = (byte) (responseData[5] & 0xFF);
                if (DBG) {
                    Log.i(TAG, String.format("supported PID at base %d payload %02X%02X%02X%02X",
                        basePid, byte0, byte1, byte2, byte3));
                }
                FourByteBitSet fourByteBitSet = new FourByteBitSet(byte0, byte1, byte2, byte3);
                for (int byteIndex = 0; byteIndex < 4; ++byteIndex) {
                    for (int bitIndex = 7; bitIndex >= 0; --bitIndex) {
                        if (fourByteBitSet.getBit(byteIndex, bitIndex)) {
                            int command = basePid + 8 * byteIndex + 7 - bitIndex;
                            if (DBG) {
                                Log.i(TAG, "command " + command + " found supported");
                            }
                            result.add(command);
                        }
                    }
                }
            }
            basePid += 0x20;
        //  最下位ビットが1なら継続(次のコマンドも送信)、0ならbreak
            if(result.contains(basePid - 1))
                Log.d(TAG,String.format("next Support id %02X supported",basePid - 1));
            else
                break;
        }
        // 追加部分終わり 
        return result;
    }

    ...

Obd2LiveFrameGenerator.java

Obd2LiveFrameGeneratorは、Obd2Connectionを引数として呼び出され、コンストラクタではObd2ConnectiongetSupportedPIDsでサポートしているPIDをList化し、Obd2Commandで設定されているPIDを突き合わせ、Java Stream API を使ってサポートのあるPIDをmIntegerCommandsmFloatCommandsというListにまとめています。

その後に、generateメソッドでmIntegerCommandsmFloatCommandsをそれぞれPIDの数だけ呼び出してコマンドを送信、受信した結果を返します。このメソッドはJsonWriterで返すことになっているので、PIDと値を対にしてJSON形式で戻すようになっています。オリジナルではJsonWriterを引数にしていますが、JsonWriterの使い方がいまいち分からなかったので引数なしで、JsonObjectにPIDと値を対にして入れて返すことにしました。

Obd2LiveFrameGenerator.java
/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.myapplication;

import android.os.SystemClock;
import android.util.Log;

import com.example.myapplication.Obd2Command.LiveFrameCommand;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;

public class Obd2LiveFrameGenerator {
    public static final String FRAME_TYPE_LIVE = "live";
    public static final String TAG = Obd2LiveFrameGenerator.class.getSimpleName();

    private final Obd2Connection mConnection;
    private final List<LiveFrameCommand<Integer>> mIntegerCommands = new ArrayList<>();
    private final List<LiveFrameCommand<Float>> mFloatCommands = new ArrayList<>();

    public Obd2LiveFrameGenerator(Obd2Connection connection)
            throws IOException, InterruptedException {
        mConnection = connection;
        Set<Integer> connectionPids = connection.getSupportedPIDs();
        Set<Integer> apiIntegerPids = Obd2Command.getSupportedIntegerCommands();
        Set<Integer> apiFloatPids = Obd2Command.getSupportedFloatCommands();
        apiIntegerPids
                .stream()
                .filter(connectionPids::contains)
                .forEach(
                        (Integer pid) ->
                                mIntegerCommands.add(
                                        Obd2Command.getLiveFrameCommand(
                                                Obd2Command.getIntegerCommand(pid))));
        apiFloatPids
                .stream()
                .filter(connectionPids::contains)
                .forEach(
                        (Integer pid) ->
                                mFloatCommands.add(
                                        Obd2Command.getLiveFrameCommand(
                                                Obd2Command.getFloatCommand(pid))));
        Log.i(
                TAG,
                String.format(
                        "connectionPids = %s\napiIntegerPids=%s\napiFloatPids = %s\n"
                                + "mIntegerCommands = %s\nmFloatCommands = %s\n",
                        connectionPids,
                        apiIntegerPids,
                        apiFloatPids,
                        mIntegerCommands,
                        mFloatCommands));
    }

//    public JsonWriter generate(JsonWriter jsonWriter) throws IOException {    オリジナルの戻り値の型はJsonWriter
    public JSONObject generate() throws IOException {
        return generate(SystemClock.elapsedRealtimeNanos());
    }

//    以下取得したデータは JsonWriter ではなく、JSONObject を使って処理、に変更
    public JSONObject generate(long timestamp) throws IOException {
        JSONObject jsonObject = new JSONObject();
        JSONArray datasArray = new JSONArray();
        try {
            jsonObject.put("type", FRAME_TYPE_LIVE);
            jsonObject.put("timestamp",timestamp);
        } catch (JSONException e) {
            e.printStackTrace();
        }
        for (LiveFrameCommand<Integer> command : mIntegerCommands) {
            try {
                Optional<Integer> result = command.run(mConnection);
                JSONObject intValues = new JSONObject();
                if (result.isPresent()) {
                    intValues.put("id",command.getPid());
                    intValues.put("value",result.get());
                    datasArray.put(intValues);
                }
            } catch (IOException | InterruptedException | JSONException e) {
                Log.w(
                        TAG,
                        String.format(
                                "unable to retrieve OBD2 pid %d due to exception: %s",
                                command.getPid(), e));
                // skip this entry
            }
        }

        for (LiveFrameCommand<Float> command : mFloatCommands) {
            try {
                Optional<Float> result = command.run(mConnection);
                JSONObject floatValues = new JSONObject();
                if (result.isPresent()) {
                    floatValues.put("id",command.getPid());
                    floatValues.put("value",result.get());
                    datasArray.put(floatValues);
                }
            } catch (IOException | InterruptedException | JSONException e) {
                Log.w(
                        TAG,
                        String.format(
                                "unable to retrieve OBD2 pid %d due to exception: %s",
                                command.getPid(), e));
                // skip this entry
            }
        }
        try {
            jsonObject.put("data",datasArray);
        } catch (JSONException e) {
            e.printStackTrace();
        }

        return jsonObject;
    }
}

bluetooth接続機能との統合

BluetoothConnection.java

OBD2アダプターをbluetoothデバイスとしてシリアルポートプロファイルで接続する部分です。BluetoothDevicemDeviceを接続したいOBD2アダプターのMAC アドレスで作成し(BluetoothAdapter.getDefaultAdapter().getRemoteDevice(String address))、BluetoothSocketmSocketを、mDevice.createRfcommSocketToServiceRecord(SERIAL_PORT_PROFILE)メソッドで関連付け、mSocket.connect()で接続する、という流れですが、一般的にはこのconnect()が時間がかかり、その間、反応しないアプリになると良くないとされているので、接続する部分は別のスレッドになるようにしています。と言っても欲しい機能は、前回作ったアプリ(【Android】 PCとBluetooth接続し、PCに文字列を送信する)と同じなので、前述のObd2ConnectionUnderlyingTransport インターフェースをimplementsして、Obd2Connectionから送受信等出来るようにしている所だけが変更点です。

BluetoothConnection.java


public class BluetoothConnection implements Obd2Connection.UnderlyingTransport {
//public class BluetoothConnection {

    //    以下、前回のBluetoothConnection.javaと同じ
    ...

}

Bluetooth接続処理

Bluetooth接続はUIスレッドのSTART ボタンが押されたときに、ServiceForegroundServiceでスタートさせ、BluetoothConnectionconnect()を呼ぶことで接続し、接続に成功するとonBluetoothConnected()メソッドが呼ばれます。Bluetooth接続後の処理もUIスレッドとは別のスレッドで実行します。onBluetoothConnected()が呼ばれたらConnectedThreadを開始する(start()を呼ぶ)ようにしておきます。

前回はOutputStreamBluetoothConnectiongetOutputStreamと関連付けて文字列をwriteすることで送信しましたが、今回は、Obd2Connectionのインスタンスを生成するときに、BluetoothConnectionを引数にし、Obd2Connection経由で送信します。Obd2Connectionのコンストラクタが呼び出されると、ELM327を設定(runInitCommands())する処理が実行されます。

さらに、Obd2LiveFrameGeneratorObd2Connectionを引数に生成し、Obd2LiveFrameGeneratorgenerate()を呼び出すことで、JSONObjectとして応答を取得します。取得したデータはファイル名をTest.csvとして保存しておきます。Context#getFilesDir()を使いましたので、data/data/[package_name]/files/Test.csvとして保存されます。一行目はデータのラベル名として、timestamp(時刻)、取得できたPID、2行目以降は実際のデータで時系列で保存することにしました。

一通りのデータが取得出来たら(generate()が終了したら)、繰り返す、としていますので、データ取得周期は車からの応答速度に依存します。まずはこの仕様で試してみます。

また、UIスレッドのSTOPボタンを押すと、onActivefalseになり、while文を抜けるようにしています。

MyService.java
package com.example.myapplication;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;

public class MyService extends Service implements BluetoothConnection.connectionInterface {

    private BluetoothConnection mBluetoothConnection;

    ConnectedThread thread;

    public IBinder onBind(Intent intent) {
        return null;
    }

    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d("MyService", "onStartCommand()");
        Log.d("MyService", "Thread name = " + Thread.currentThread().getName());

        int requestCode = intent.getIntExtra("REQUEST_CODE",0);
        Context context = getApplicationContext();
        String channelId = "default";
        String title = context.getString(R.string.app_name);

        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {

            PendingIntent pendingIntent =
                    PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_IMMUTABLE);

            NotificationManager notificationManager =
                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

            // Notification Channel 設定
            NotificationChannel channel = new NotificationChannel(
                    channelId, title, NotificationManager.IMPORTANCE_DEFAULT);

            if (notificationManager != null) {
                notificationManager.createNotificationChannel(channel);

                Notification notification = new Notification.Builder(context, channelId)
                        .setContentTitle(title)
                        // android標準アイコンから
                        .setSmallIcon(android.R.drawable.ic_media_play)
                        .setContentText("MediaPlay")
                        .setAutoCancel(true)
                        .setContentIntent(pendingIntent)
                        .setWhen(System.currentTimeMillis())
                        .build();

                // startForeground
                startForeground(1, notification);

            }
        }

        String address = intent.getStringExtra("BT_address");

        if(mBluetoothConnection == null){
                connectDevice(address);
        } else {
            mBluetoothConnection.close();
            mBluetoothConnection = null;
                connectDevice(address);
        }

        return START_NOT_STICKY;
    }
    @Override
    public void onDestroy(){
        thread.cancel();
    }

    private void connectDevice(String address){
        // Get the BluetoothDevice object
        Log.d("connectDevice ", "address = " + address);
        mBluetoothConnection = new BluetoothConnection(this, address);
        mBluetoothConnection.setConnectionListener(this);
        mBluetoothConnection.connect();
    }

    @Override
    public void onBluetoothConnected() {
        thread = new ConnectedThread();
        thread.start();
    }

    @Override
    public void onBluetoothConnectFailed() {
        Log.d(getClass().getSimpleName(), "onBluetoothConnectFailed");
    }

    private class ConnectedThread extends Thread {
        boolean onActive = true;
        Obd2Connection mObd2Connection;
        Obd2LiveFrameGenerator mObd2LiveFrameGenerator;

        public void run() {
            Log.d("ConnectedThread run", "Thread name = " + Thread.currentThread().getName());
            mObd2Connection = new Obd2Connection(mBluetoothConnection);
            try {
                mObd2LiveFrameGenerator = new Obd2LiveFrameGenerator(mObd2Connection);
                File myDataFile = new File(getApplicationContext().getFilesDir(), "test.csv");
                FileWriter myDataFileWriter = new FileWriter(myDataFile);
                boolean needTitle = true;
                while (onActive) {
                    JSONObject jsonObject = mObd2LiveFrameGenerator.generate();
                    Log.d("ConnectedThread run", "jsonObject = " + jsonObject.toString());

                    long timestamp = jsonObject.getLong("timestamp");
                    JSONArray datas = jsonObject.getJSONArray("data");

                    StringBuilder titles = new StringBuilder();
                    StringBuilder values = new StringBuilder();
                    titles.append("timestamp");
                    Date datetime = new Date(timestamp);
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS", Locale.JAPAN);
                    values.append(sdf.format(datetime));

                    if(datas.length() > 0){
                        for(int j = 0; j < datas.length(); j++){
                            JSONObject data = datas.getJSONObject(j);
                            titles.append(",").append(data.get("id"));
                            values.append(",").append(data.get("value"));
                        }

                        if(needTitle){
                            myDataFileWriter.write(titles + "\r\n");
                            needTitle = false;
                        }
                        myDataFileWriter.write(values + "\r\n");
                    }
                }
                myDataFileWriter.close();

            } catch (IOException | InterruptedException | JSONException e) {
                e.printStackTrace();
                onActive = false;
                Log.d(getClass().getSimpleName(), "onBluetoothDisconnected");
                e.printStackTrace();
            }
            //  正常終了時   異常時はonBluetoothDisconnected()の方が早い→mBluetoothConnection = nullの可能性もあり
            if (mBluetoothConnection != null) {
                mBluetoothConnection.close();
                Log.d("ConnectedThread", "mBluetoothConnection.close()");
                mBluetoothConnection = null;
            }

            Log.d("ConnectedThread", "end");
        }
        public void cancel(){
            onActive = false;
            Log.d("ConnectedThread", "cancel");
        }
    }

統合後の構成

統合後のファイル構成は以下のようになりました。
obd2-lib統合後.PNG

動作確認

PCでの確認

車での動作確認をする前に、PCを相手に通信接続、コマンド送信が出来ているか、確認します(※Teratermの設定は前回の記事と同じです)。

アプリのオプションメニュー →「Connect a device」→ 受信側のPCを選択

をすると、一度最初の画面に戻るので、STARTボタン押します。

Teratermの画面に「ATD」と表示されていれば、接続と、Obd2Connectionのインスタンスの生成、コンストラクタにあるELM327の設定まではたどり着いています。
ATD.PNG

Teratermのコンソールから>を入力するとObd2ConnectionString[] initCommandsの内容が次々に送信されてきます。 "ATZ", "AT E0", "AT L0", "AT S0", "AT H0", "AT SP 0"まで順番に送信されてくるので、同じく>を入力すると、次はObd2LiveFrameGeneratorのインスタンスが生成され、Support PIDの確認に進みます。
0100まで.PNG

"0100"が送信されてくるので、「OBD-II PIDs」にならって、4100BE1FA813 と打ち込みます。
ATCommands_supportPIDs.PNG

Android Studio の Logcatもしくは、debugのコンソール画面に

I/Obd2LiveFrameGenerator: connectionPids = [32, 1, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16, 17, 19, 21, 28, 31]
apiIntegerPids=[3, 5, 10, 92, 12, 13, 31]
apiFloatPids = [17, 4, 70, 6, 7, 8, 9, 47]
mIntegerCommands = [com.example.myapplication.Obd2Command$LiveFrameCommand@f687555, ...(以下、省略)
mFloatCommands = [com.example.myapplication.Obd2Command$LiveFrameCommand@48811d1, ...(以下、省略)

と表示されました。connectionPidsに出てきている数字は、「OBD-II PIDs」

So, supported PIDs are: 01, 03, 04, 05, 06, 07, 0C, 0D, 0E, 0F, 10, 11, 13, 15, 1C, 1F and 20

と一部順番が違いますが一致しています。connectionPidsは10進、こっちは16進ですね。

次のapiIntegerPidsapiFloatPidscommandsに用意されているPIDのうち、戻り値がInteger扱いのPID、Float扱いのPIDがそれぞれ表示されています。

apiIntegerPids=[3, 5, 10, 92, 12, 13, 31]	→ 16進で 03, 05, 0A, 5C, 0C, 0D, 1F
apiFloatPids = [17, 4, 70, 6, 7, 8, 9, 47]	→ 同じく 11, 04, 46, 06, 07, 08, 09, 2F

mIntegerCommands、mFloatCommands はapiIntegerPidsapiFloatPidsに含まれるPIDのうち、サポートされているPIDのListになっているはずです。ところが、オリジナルのコードではLogの出力にハッシュ値が出力されていて、ちゃんと動いているかわからないでObd2CommandクラスにtoStringメソッドをオーバーライドして

Obd2Command.java
    @Override
    public String toString(){
        return Integer.toString(mSemanticHandler.getPid());
    }

を追加すると、

mIntegerCommands = [3, 5, 12, 13, 31]
mFloatCommands = [17, 4, 6, 7]

となって、supported PIDs に含まれるPIDだけがちゃんと抽出されているのが確認できました。

TeraTermのコンソール上に0103が最後に表示されていますが、mIntegerCommandsmFloatCommandsの順でそれぞれの配列に含まれるPIDの取得要求を出していくことになっているので、その最初のPIDが03、このPIDの現在の値を要求するコマンドは01 03なのでそれが出力されているということで、そこまでは予定通りに動いていることが確認できました。

車での確認

PCを使っての動作確認が出来ましたので、次は実際に車で確認します。

機材等

  • 技適マーク付与されているOBD2アダプターとして「CARISTA OBD2 アダプタ」を使用
  • トヨタ ヴォクシー(ZRR70)

車の故障診断コネクターにOBD2アダプターを取り付け、エンジンを始動します。次に、スマホのBleutooth設定画面から、OBD2アダプターをペア設定し、接続します。スマホアプリを起動し、オプションメニュー →「Connect a device」→ ペア設定したOBD2アダプターを選択、STARTボタンを押します。正常に動作していると、画面の左上にForegroundServiceで使用されるNotificationで設定したアイコンが表示されます。

しばらく待って、STOPボタンを押し、Android Studioが作動しているPCに接続していない場合は接続し、Android Studioの右下にある「Device File Explorer」から
data/data/[package_name]/filesを開くと、Test.csvが出来ていました。
Test_csv.PNG

右クリックでメニューが出て、「Save As...」でデスクトップ等に保存して内容を確認すると、先程のサポートされているPIDの値が計測開始からの時間と一緒に保存されていました。一行目は、項目名です。PIDの数字だけではわかりにくいですが、そこは別途考えましょう。

PID 12(10進)はEngine speed(エンジン回転数、RPM)、5はEngine coolant temperature(エンジン冷却水温度)ですので、グラフにしてみるとエンジン始動直後のエンジン回転数の動き、エンジン冷却水温度が少しずつ上昇していく様子が分かりました。
graph.PNG

※本記事は車のOBD(故障診断)コネクターへのOBD2アダプター装着を推奨したり、安全を保証するものではありません。取扱説明書等で装着を認めていない車もあります。

使い方

今回のような車のデータを取るアプリで何が分かるか調べた結果を別途まとめました。

参考にした記事等

2
3
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?