バイクのOBD2とVRMキャラの表情を連動させて遊ぶ

# はじめに
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/be11d4d5-deae-08c8-b9fd-95b871e18c9a.png)

:::note alert
OBD2端子への機器の接続やアクセスは場合によっては交通事故につながるような故障を引き起こすかもしれません。
これはハードウェア的な故障かもしれませんし、ソフトウェア的な問題かもしれません。
この記事を参考にしたことによる事故等に対する責任は一切負いませんので、試す場合は各自念入りな調査の上、自己責任で行ってください。

特に、市販のOBD2アダプタには、日本の電波法規を遵守していない製品や、正しく動作しない製品、車両に不具合を起こす製品が多数存在していることが以前より知られていますので、注意深く選定してください。
:::

:::note warn
道路交通法 第七十一条 （運転者の遵守事項）五の五　
> (前略) 又は**当該自動車等に取り付けられ若しくは持ち込まれた画像表示用装置**（道路運送車両法第四十一条第一項第十六号若しくは第十七号又は第四十四条第十一号に規定する装置であるものを除く。第百十八条第一項第四号において同じ。）**に表示された画像を注視しないこと。**

この規定はスマートフォンのほか、スマートモニターや自作した装置も対象となると推定されます。運転中に注視および交通の危険を生じさせないようにご注意ください。
:::

今年の1月に普通二輪免許を取りまして、3月からバイクに乗っています。
電子工作とプログラミングを趣味にしている私ですので、バイクにも当然色々付けてみたくなるわけです。

とりあえずGPS/GNSSモジュールを使って車速やら高度やら位置やら取って遊んでみたりしました。

https://note.com/gpsnmeajp/n/nb19834c9aa4e

GPS/GNSSで遊んでいると不満に感じることが一つあります。
それは、トンネルや地下で情報が取れないことです。

GPS速度はあくまで測位の結果得られるものですから、測位できないトンネルや地下では得られる情報がありません。
とはいっても、日本の高速道路なんてトンネルだらけなわけです。

では、トンネル内でも使える手段は何でしょうか？
信頼性の低い方法を除けば概ね以下となります。

1. タイヤの回転を磁気センサーなどで取る
1. スピードメーターから信号を取り出す
1. 車載コンピュータ(ECU)のOBD2(自己診断端子)から取り出す

1や2は、社外品スピードメーターなんかをつなぐときによく使われます。
+ メリットとしては、車載コンピュータに依存しない汎用的な仕組みであり、単純であることが売りです。
+ デメリットとしては、加工が大変だったり駆動部分に近いところをいじらないといけない点です。

3は、CAN信号を読み込んだり、市販のOBD2アダプターを使うなどの方法でアクセスできます。
+ メリットとしては、スピード以外の多種多様なデータも取れ、車両状態をモニタリングしたり、スマホナビの位置補正に使ったりできます。異常状態の取得やリセット、場合によっては隠し機能の有効化などもできるらしいです。
+ デメリットとしては、車載コンピュータに接続するのでECU故障のリスクがあります。セキュリティ的な問題もあります。後述しますがELM327偽物問題などもあります。

https://en.wikipedia.org/wiki/On-board_diagnostics

https://corporate.navitime.co.jp/topics/pr/202304/20_5580.html

今回は、OBD2で遊ぶことにしました。
私のバイクはジクサー250(ED22Y)なので、この車種の場合の話となります。

なお、OBD2は、排ガス規制のために法律で義務化されており、2022年11月以降に生産されたバイクであれば必ず付いています。めちゃくちゃ最近ですね。
それ以前の車種については、付いているものもあれば、似て非なるものが付いていることもあります。

https://mc-web.jp/life/zatsuneta/67678/


# OBD2アダプタの選定と、ELM327偽物問題について
最初はOBD2のアクセスのために、回路を自作してCANバスにつなごうと思っていました。
なぜなら、信用できない回路(偽物)を掴まされて車載コンピュータに接続したくなかったからです。

市販のOBD2アダプタには、ELM327というチップが一般に使用されています。
Microchip社のマイコンに、カナダのElm Electronics社が便利なプログラムを書き込んだチップの構成になっており、ELM327はOBDの通信をRS232に解釈・変換してくれる非常に便利なチップです。Windows95の時代からあります。

https://web.archive.org/web/20210715033626/https://www.elmelectronics.com/

OBDの通信は割と結構面倒な構成で考えることや処理も多いのですが、ELM327を使うと、初期化や自動判別までチップ側で行ってくれ、人間がWikipediaを見ながらTeraTermで叩けるくらい簡単になります。

なので非常に人気があり、汎用のOBD2アダプタであればほぼ必ずELM327互換のコマンドセットが採用されています。
こうしないと巷のプログラムが全く動かないからです。

ところが、人気のハードウェアといえば偽物が付き物です。
チップ自体も、OBD2アダプタも大量の偽物が出回っています。

この偽物が厄介で、完璧に機能するものもありますが、一部だけ正しく動くもの、偽の応答を返し異常な挙動をするもの、場合によっては車両の動きをおかしくするものまであります。

https://incardoc.com/en-us/thread/Attention-Bad-Bluetooth-adapters-/

「じゃあ、正規品のチップを採用したものを買えばいいじゃないか」あるいは「正規品のチップで回路を組めばいいじゃないか」と思うかと思います。
残念ですが、Elm Electronics社は2022年にコロナ禍の影響により事業を終了しました。

https://web.archive.org/web/20210615043339/https://www.elmelectronics.com/elm-is-closing

じゃあどうしましょうか。互換性を謳っている製品は大量にありますが、正規品がもうない以上、乗っているのは高品質な互換品か、低品質な互換品のどちらかでしょう。なんなら技適が通っているかも怪しい製品だらけです。

しかし回路の自作も結果的には諦めました。自分で作った回路ほど信用できないものはないためです。リファレンスになるものもない状態で開発してもうまくいくはずがありません。

そこで私は、冒頭のカーナビタイムのOBD2対応に目をつけました。この対応は2023年に行われています。

https://blog.navitime.co.jp/carnavitime_and_renew/2023/04/obd2.html

読んで見ると、ELM327互換、Bluetooth4.0接続、技適ありのアダプタを要求していることがわかります。
ナビタイム自体が製造しているわけではないため、動作確認済みのものがないかと見てみたところ、記載がありました。

製品ページを探そうとしたところ、カーナビタイム公式Xにamazonへのリンクも有りました。

https://x.com/navitime_drive/status/1649237840805629953

色々調べた結果、CARISTA OBD2アダプタを使用することにしました。
価格が手頃なのと、形状にクセがなく、そして公式に下記の記事を作成しているのが理由です。
端的に内容をまとめると「市場のOBD2アダプタが信頼できないので、ELM327 v1.4に完全に準拠した独自のアダプタを自社開発した」というものです。

https://carista-japan-officialsupport.zendesk.com/hc/ja/articles/34664319909529-OBD2%E3%82%A2%E3%83%80%E3%83%97%E3%82%BF%E3%81%AE%E7%9C%9F%E5%AE%9F

また、CARISTAはフォルクスワーゲンとの取引もあるようです。

届いてこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/4a962a51-3b64-a259-23d5-5da10ee73e5b.png)


# 変換ハーネス
OBD2の端子は規格で決まっています。
なので、OBD2アダプタも皆同じような形状になっています。

が、これは車の場合です。バイクは同じ形状を採用していません。

各社バラバラの端子を採用していた時代もあるようですが、おそらく法規適用後の現在は赤色の6Pカプラーを採用しているようです。一般にホンダ用として売られていますが、ISO準拠のカプラーらしく、ジクサー250(ED22Y)もこれでした。

以下はネット上の情報(ED22B)を元に間違えて買ったアダプタですが、これにもシールでその旨が記載されてますね

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/5ead0255-912d-955f-fa96-268b5ba95f27.png)

正しいのはこっちです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/3d47afc8-18a4-a056-fb8d-4a629a5c5753.png)

https://www.amazon.co.jp/%E3%82%AD%E3%82%BF%E3%82%B3-KITACO-Diagnostics-%E3%80%81CRF1100L-764-9000110/dp/B084HX5BXK?th=1

接続端子はリアシート下の、工具入れの下に、キャップが付いた状態で入っています。
接続してキーを回すと、このように光ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/804440c5-cb4e-4441-a3a8-1fc4c40fd213.png)

OBD2アダプタはバイク用の製品ではなく、防水性とかはないので、防水テープとかで巻いて置きましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/8268ffdb-db0e-2ebb-ce64-1b131ff3e53f.png)

CARISTAのアダプタの場合、公式アプリをいれるとこのように取得可能な情報を全部出してくれます。
車両と違って項目数も少ないですが、OBD2規格の標準で対応可能な範囲の情報は出してくれます。
(左半分: CARISTA公式アプリ, 右半分: ツーリングサポーター)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/d61cbe52-ef17-e2a7-20b7-89ef9d27e45a.png)

# 自作プログラムで通信
公式アプリや、各種フリーのアプリなどを使って色々出来ますが、技術者なら自分でいじってみたいでしょう。
というかそのために購入したわけです。
作業可能な場所を確保して、通信してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/d45eabad-b824-a94d-56b5-164f2ce64f6a.png)

Bluetooth4.0自体については、以下の書籍などで勉強してみてください。(私もこの本を読みました)

https://www.amazon.co.jp/Bluetooth-Low-Energy%E3%82%92%E3%81%AF%E3%81%98%E3%82%81%E3%82%88%E3%81%86-Make-PROJECTS/dp/4873117135/ref=sr_1_2?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&crid=2Z2YU5XRD7TS9&dib=eyJ2IjoiMSJ9.IuFaoDEuTtEPZHpu97DKt2O-4WNPIjD4gs9RVw7YMTGjzQE4Iq_Umh4JcWziTiXWiVPnTZ16nGQ13CqTvp-TNCSpjjzqTJz4a-hmqefjLPVqw67v5BHwyt-4Bmu61hfnYGLVRceNmeM1ZdUjqetOHr-wCLbVJ05XD4fHQd_N7qn6btO45g_XcEJeJARnxIDLLzTZfOAUk7ZkDm0L2cywLCzD1i9trpL2xlhdAyeAllI.xz4hrwXV-rmdwfVp8mpTtpZpy_ywWIbdq1q-UkaNsmQ&dib_tag=se&keywords=Bluetooth+LE&qid=1730041411&s=books&sprefix=bluetooth+le%2Cstripbooks%2C232&sr=1-2

Bluetooth4.0によるOBD2アダプタとの通信の仕方の概要については、ナビタイムの記事が参考になります。

https://note.com/navitime_tech/n/n2e89aef49382

GATTのサービス・キャラクタリスティックについては、以下の構成が一般的なようです。

+ Service FFF0
+ Rx characteristic FFF1 (Notify, Indicate)
+ Tx characteristic FFF2 (Write, Write without Response)

ELM327はシリアル通信するためのチップのため、そこにBluetooth LEを噛ませたような構成になっています。(BBC micro:bitのBluetooth UART serviceによく似ています)

Carista以外の機器をお使いの場合は、各自LightBlueなどのBLE調査アプリを使って調べてみてください。
LightBlueでは仮想デバイスを作成する機能があるため、上記の構成を再現することで動作確認ができます。


## Pythonによる対話

Pythonと、Windows/Mac/Linuxのいずれかの環境、Bluetoothアダプタがあれば、以下のようなコードで対話が可能です。(Windows 11で動作確認しています)
コードはChatGPTで作成してもらい、手直ししています。

```py
import asyncio
from bleak import BleakClient, discover

# デバイスの名前
device_name = "Carista"
# サービスとキャラクタリスティックのUUID
notify_characteristic_uuid = "0000FFF1-0000-1000-8000-00805f9b34fb"
write_characteristic_uuid = "0000FFF2-0000-1000-8000-00805f9b34fb"

async def notify_handler(sender, data):
    """通知を処理するコールバック関数"""
    print(f"* {data.decode('utf-8')}")

async def send_input(client):
    """ユーザーからの入力を受け取って送信するタスク"""
    while True:
        user_input = await asyncio.get_running_loop().run_in_executor(None, input, "")
        user_input += "\r"  # 改行コードを追加
        await client.write_gatt_char(write_characteristic_uuid, user_input.encode('utf-8'))

async def main():
    # デバイスのスキャン
    devices = await discover()
    device = next((d for d in devices if d.name == device_name), None)

    if device is None:
        print(f"Device {device_name} not found!")
        return

    async with BleakClient(device) as client:
        print(f"Connected to {device_name}")

        # 通知の購読
        await client.start_notify(notify_characteristic_uuid, notify_handler)

        # 入力受付タスクの実行
        await send_input(client)

if __name__ == "__main__":
    asyncio.run(main())
```

実行するとターミナルのように対話動作できる状態になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/9d05173f-ab32-4ab9-e8f0-9bd31ecb3097.png)

コマンドについてはELM327のデータシートを参照してください。

https://www.elmelectronics.com/DSheets/ELM327DSH.pdf

ATZでリセット、ATRVで電圧が読み取れます。これは車載コンピュータと接続していなくても電源供給さえしていればできるようです。
01 00で、OBD2のShow current data, PIDs supported [$01 - $20] の問い合わせになります。返ってくる内容は、車載コンピュータの$01 - $20の応対可能可否のビット列です。

エンジンの回転数などを取得したい場合は、Wikipediaを参考にコマンドを叩いてみてください。

https://en.wikipedia.org/wiki/OBD-II_PIDs

なお、本来OBD2のCAN通信はこんなに単純なものではなく、ELM327が大幅に抽象化・簡単化してくれています。詳細はVector社の「はじめての診断」PDFなどを参照してください。

## Pythonによるエンジン回転数の自動取得
パースの邪魔になるエコーやヘッダをオフにし、01 0C (エンジン回転数)の要求を定期的に出して回転数を取得するサンプルです。
コードはChatGPTで作成してもらい、手直ししています。換算式などの面倒くさいものも「エンジン回転数を取り出したい」というだけで勝手に作ってくれるので便利です。

```py
import asyncio
from bleak import BleakClient, BleakScanner

CARISTA_DEVICE_NAME = "Carista"
SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb"
CHAR_NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"
CHAR_WRITE_UUID = "0000fff2-0000-1000-8000-00805f9b34fb"
RPM_COMMAND = "010C\r"  # OBD-II command to get engine RPM

# Initial ELM327 setup commands to avoid echo and extra data
ELM327_SETUP_COMMANDS = [
    "ATE0\r",  # Turn off echo
    "ATS1\r",  # Turn on spaces
    "ATH0\r",  # Turn off headers
]

async def find_device_by_name(name):
    devices = await BleakScanner.discover()
    for device in devices:
        if device.name and name in device.name:
            return device
    return None

async def notification_handler(sender, data):
    # Decode the UTF-8 response from the device (hex string)
    response = data.decode('utf-8').strip()
    print(f"Received: {response}")

    # Only process lines containing the RPM response (it starts with "41 0C")
    if response.startswith("41 0C"):
        try:
            # Split the response into its components (e.g., "41 0C A B")
            parts = response.split(" ")
            A = int(parts[2], 16)  # Convert from hex to int
            B = int(parts[3], 16)  # Convert from hex to int
            rpm = ((A * 256) + B) / 4
            print(f"RPM: {rpm}")
        except (IndexError, ValueError):
            print("Error processing RPM data")

async def main():
    device = await find_device_by_name(CARISTA_DEVICE_NAME)
    if not device:
        print(f"Device '{CARISTA_DEVICE_NAME}' not found.")
        return

    async with BleakClient(device) as client:
        print(f"Connected to {CARISTA_DEVICE_NAME}")

        # Initial setup to disable echo and unnecessary characters
        for cmd in ELM327_SETUP_COMMANDS:
            await client.write_gatt_char(CHAR_WRITE_UUID, cmd.encode('utf-8'))
            await asyncio.sleep(0.5)  # Small delay between setup commands

        # Subscribe to notifications
        await client.start_notify(CHAR_NOTIFY_UUID, notification_handler)

        while True:
            # Write the RPM command to the device
            await client.write_gatt_char(CHAR_WRITE_UUID, RPM_COMMAND.encode('utf-8'))
            await asyncio.sleep(1)  # Wait for 1 second before sending the next request

# Start the asyncio event loop
asyncio.run(main())
```

実行した状態でエンジンをかけると、以下のように回転数が表示されます。
ジクサーはタコメーターが付いていますので、バーグラフと合うか確認してみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/e58ebe2f-2afc-1abe-5a87-4dafddfbdb55.png)

## Web Bluetoothで動かしてみる
さて、せっかくならバイクの上で動かしたいわけですが、PCを設置するわけには行きません。
私のバイクにはAndroidタブレットが備え付けられていますから、それを使いたいところです。

とはいえ、Androidアプリをビルドしてデバッグする気力はなかったので、ブラウザで動かせるWebBluetoothを使用することにしました。こうすることで、出先でも修正が簡単になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/ccdf4602-0ab1-b277-871d-95e37cb1441b.png)

PythonのコードをChatGPTに移植してもらいました。

最終形のものから持ってきていますので、色々手直しやその他が入っています。

WebBluetoothはセキュリティの都合上、接続のために必ずユーザーのインタラクションが必要なことに注意してください。

```js
"use strict";

// 車両デバイスのBLEサービスとキャラクタリスティックUUID
const CARISTA_DEVICE_NAME = "Carista";
const SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb";
const CHAR_NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb";
const CHAR_WRITE_UUID = "0000fff2-0000-1000-8000-00805f9b34fb";

// OBD-IIコマンド：取得する車両情報
const RPM_COMMAND = "010C\r";         // エンジン回転数
const SPEED_COMMAND = "010D\r";       // 車両速度
const THROTTLE_COMMAND = "010B\r";    // 絶対アクセル開度
const ENGINE_LOAD_COMMAND = "0104\r"; // エンジン負荷
const ENGINE_TIME_COMMAND = "011F\r"; // エンジン始動からの時間
const INTAKE_TEMP_COMMAND = "010F\r"; // 吸気温度
const COOLANT_TEMP_COMMAND = "0105\r"; // 冷却水温度

const TIMEOUT_MS = 300;              // タイムアウト時間（ミリ秒）

// 初期化時のコマンド
const ELM327_SETUP_COMMANDS = [
    "ATE0\r",  // エコー無効化
    "ATH0\r",  // ヘッダー無効化
    "ATS1\r"   // スペース有効化
];

// グローバルな車両状態オブジェクトとイベントディスパッチャー
let vehicleStatus = {
    rpm: null,
    speed: null,
    throttle: null,
    engineLoad: null,
    intakeTemp: null,
    coolantTemp: null,
};
const vehicleStatusEvent = new Event("vehicleStatusUpdated");
const vehicleErrorEvent = new Event("vehicleStatusError");

let characteristicNotify, characteristicWrite;
let awaitingResponse = false;
let highFreqInterval, lowFreqInterval;
let timeout;

// デバイスに接続し、サービスとキャラクタリスティックを取得
async function connectDevice() {
    try {
        const device = await navigator.bluetooth.requestDevice({
            filters: [{ name: CARISTA_DEVICE_NAME }],
            optionalServices: [SERVICE_UUID]
        });

        console.log(`Connected to ${device.name}`);
        const server = await device.gatt.connect();
        const service = await server.getPrimaryService(SERVICE_UUID);

        // 通知と書き込み用のキャラクタリスティック取得
        characteristicNotify = await service.getCharacteristic(CHAR_NOTIFY_UUID);
        characteristicWrite = await service.getCharacteristic(CHAR_WRITE_UUID);

        // 初期設定のコマンド送信
        for (const cmd of ELM327_SETUP_COMMANDS) {
            await writeCommand(cmd);
            await delay(500); // 送信後の待機
        }

        // 通知リスナー開始
        await characteristicNotify.startNotifications();
        characteristicNotify.addEventListener('characteristicvaluechanged', handleNotifications);

        // データ取得タイマー開始
        highFreqInterval = setInterval(requestHighFreqData, 200);
    } catch (error) {
        console.error(`Error: ${error}`);
    }
}

// 高頻度データ（RPM、速度、アクセル開度、エンジン負荷）をリクエスト
let highFreqCommands = [RPM_COMMAND, SPEED_COMMAND, THROTTLE_COMMAND, ENGINE_LOAD_COMMAND, INTAKE_TEMP_COMMAND, COOLANT_TEMP_COMMAND];
let highFreqIndex = 0;

async function requestHighFreqData() {
    if (!awaitingResponse && highFreqCommands.length > 0) {
        const command = highFreqCommands[highFreqIndex];
        await sendCommandWithTimeout(command);
        highFreqIndex = (highFreqIndex + 1) % highFreqCommands.length;
    }
}

// コマンドをデバイスに送信し、応答を待機
async function sendCommandWithTimeout(command) {
    awaitingResponse = true;
    timeout = setTimeout(() => {
        console.warn(`Timeout: No response for ${command}`);
        awaitingResponse = false;
    }, TIMEOUT_MS);

    await writeCommand(command);
}

// コマンドをUTF-8でデバイスに書き込む
async function writeCommand(command) {
    if (characteristicWrite) {
        const encoder = new TextEncoder();
        const commandBytes = encoder.encode(command);
        await characteristicWrite.writeValue(commandBytes);
    }
}

// 通知データを解析し、必要に応じてイベントを発火
function handleNotifications(event) {
    const decoder = new TextDecoder("utf-8");
    let response = decoder.decode(event.target.value).trim();
    console.log(`Received: ${response}`);
    const result = response.match(/41.*/);
    if (result) { response = result[0] }

    try {
        if (response.startsWith("41 0C")) {
            clearAwaitingResponse();
            parseRPM(response);
        } else if (response.startsWith("41 0D")) {
            clearAwaitingResponse();
            parseSpeed(response);
        } else if (response.startsWith("41 0B")) {
            clearAwaitingResponse();
            parseThrottle(response);
        } else if (response.startsWith("41 04")) {
            clearAwaitingResponse();
            parseEngineLoad(response);
        } else if (response.startsWith("41 1F")) {
            clearAwaitingResponse();
            parseEngineTime(response);
        } else if (response.startsWith("41 0F")) {
            clearAwaitingResponse();
            parseIntakeTemp(response);
        } else if (response.startsWith("41 05")) {
            clearAwaitingResponse();
            parseCoolantTemp(response);
        }

    } catch (error) {
        console.error(error);
        document.dispatchEvent(vehicleErrorEvent);
    }
}

function clearAwaitingResponse() {
    clearTimeout(timeout);
    awaitingResponse = false;
}

// 各情報ごとのパース関数（更新後にイベント発火）
function parseRPM(response) {
    const parts = response.split(" ");
    const rpm = ((parseInt(parts[2], 16) * 256) + parseInt(parts[3], 16)) / 4;
    if (vehicleStatus.rpm !== rpm) {
        vehicleStatus.rpm = rpm;
        dispatchVehicleStatusUpdate();
    }
}

function parseSpeed(response) {
    const speed = parseInt(response.split(" ")[2], 16);
    if (vehicleStatus.speed !== speed) {
        vehicleStatus.speed = speed;
        dispatchVehicleStatusUpdate();
    }
}

function parseThrottle(response) {
    const throttle = parseInt(response.split(" ")[2], 16) * 100 / 255;
    if (vehicleStatus.throttle !== throttle) {
        vehicleStatus.throttle = throttle.toFixed(2);
        dispatchVehicleStatusUpdate();
    }
}

function parseEngineLoad(response) {
    const load = parseInt(response.split(" ")[2], 16) * 100 / 255;
    if (vehicleStatus.engineLoad !== load) {
        vehicleStatus.engineLoad = load.toFixed(2);
        dispatchVehicleStatusUpdate();
    }
}

function parseIntakeTemp(response) {
    const temp = parseInt(response.split(" ")[2], 16) - 40;
    if (vehicleStatus.intakeTemp !== temp) {
        vehicleStatus.intakeTemp = temp;
        dispatchVehicleStatusUpdate();
    }
}

function parseCoolantTemp(response) {
    const temp = parseInt(response.split(" ")[2], 16) - 40; // OBD-II の冷却水温度は40を引く
    if (vehicleStatus.coolantTemp !== temp) {
        vehicleStatus.coolantTemp = temp;
        dispatchVehicleStatusUpdate();
    }
}

// 車両状態の更新イベントを発火
function dispatchVehicleStatusUpdate() {
    console.log(vehicleStatus);
    document.dispatchEvent(vehicleStatusEvent);
}

// 指定時間待機するヘルパー関数
async function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 接続ボタンをクリック時にconnectDeviceを呼び出すイベントリスナー
document.getElementById("connectButton").addEventListener("click", connectDevice);
```

車両状態の取得に成功し、変化があるたびにvehicleStatusUpdatedが発火しますので、vehicleStatusを読み込んでください。
HTTPSが使えるWebサーバーにアップロードし、適当なHTMLと組み合わせると、以下のような感じに表示することが出来ます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/85b8a475-e29b-ed17-d3f5-4f6daa9d461b.png)

## VRMと組み合わせる
ただ表示するだけだと面白くないですね。
せっかくなので3Dキャラクターを表示して、表情と連動させてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191114/abe1496a-80f7-bd8f-0fa3-7811dee1b12b.png)

私は愛機の擬人化キャラクターをすでに作っていたのでそれを使うことにしますが、もし追試されたい場合は、適当なVRoidのサンプルアバターでもお使いください。

:::note warn
重いアバターだとスマホ等の機器では正常に動かない場合があります。軽いテクスチャ、ポリゴンのものを推奨します。
:::

とは言ってもやることは単純です。

Pixiv社が開発しているthree-vrmを使うと、VRMモデルを簡単にブラウザ上に表示することが出来ます。

https://github.com/pixiv/three-vrm

Examplesのexpressions.htmlに、先程のjavascriptを組み込んでください。

https://pixiv.github.io/three-vrm/packages/three-vrm/examples/

HTMLはお好みで調整してください。
canvasをHTML内で作成したものとし、出力をアルファ値有効にすると、テキスト等と重ね合わせできて便利です。

```js
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("gl"), antialias: true, alpha: true });
```

カメラなどもいい感じに設定してください。デフォルトのままであれば、スワイプやピンチで制御することも出来ます。

:::note warn
運転中に注視・操作しないようにご注意ください
:::

表情制御に、vehicleStatusを参照するように設定します。

```js
        const l = vehicleStatus.engineLoad !== null ? vehicleStatus.engineLoad : 0.0;
        const s = vehicleStatus.speed !== null ? vehicleStatus.speed : 0.0;
        currentVrm.expressionManager.setValue('angry', l / 100.0);
        currentVrm.expressionManager.setValue('happy', s / 100.0);
        currentVrm.expressionManager.setValue('sad', vehicleStatus.rpm == 0 ? 1.0 : 0.0);

        currentVrm.humanoid.getNormalizedBoneNode('leftUpperArm').rotation.z = Math.PI / 180.0 * 70;
        currentVrm.humanoid.getNormalizedBoneNode('rightUpperArm').rotation.z = Math.PI / 180.0 * -70;

        // update vrm
        currentVrm.update(deltaTime);
```

ここでは、

+ 怒り=エンジン負荷計算値(吸気量と燃料と回転数の比率らしい。惰性走行時0になる)
+ 笑顔=速度
+ 悲しみ=回転数 is 0

で計算するように組みました。
実動作としては、

+ 速度出ない上り坂とか発進はしんどそうな表情をする
+ 速度出てる下り坂は楽しそうな表情をする
+ 高速巡航時は楽しそうに頑張る
+ エンストすると悲しそうな顔をする

という動きになります。これに関しては、お好みに合わせて変更してみてください。

実際の動きはこんな感じです。(Insta360で撮影したものの切り出しです)

<iframe width="522" height="928" src="https://www.youtube.com/embed/kzJksVcW_8Q" title="2024年10月27日" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
流れ: 高速で速度乗ってて楽しい→PAに向けて惰性走行して楽→低速しんどい→エンジン切られてしょんぼり

# 結び
車両情報を比較的容易に取得できるELM327は便利です。
バイクにおいては、比較的新しい車両限定とはなりますが、こういった車両状態が取得できるのは非常に便利な場面があると思います。

将来的にはChatGPT APIと連携させたり、音声合成と組み合わせたりして、バイクと対話できるようになったら面白いなと思っています。

ただ、WebBluetoothは、残念ながらこの用途にはあまり向いていないということも付け加えさせていただきます。
これは以下のような理由です。

+ セキュリティ上の理由により、エンジンかけるたび接続ボタンを押す必要がある
+ 接続に失敗するタイミングがある
+ 全く接続・通信できなくなることがある

なにかの参考になれば幸いです。

## おまけ
GPT-4oにPythonコードの作成を依頼したときのプロンプトを掲載します。
ほとんど本記事のコードに近いコードが生成されるはずですが、このプロンプトで出力されるコードには誤りがありますので手修正や対話による修正をする必要があります。
(セパレータをオフにしたうえでセパレータを当てにしたパースをしよう、入力がバイナリだと思い込むなどの問題が起きます。)

```
BLEを使ったシリアル通信ソフトを作成します。

pythonでbleakを使って、"Carista"という名前のデバイスを探して接続してください。
その後、サービス0xFFF0の、キャラクタリスティック0xFFF1がIndicate, Notify属性を持っていますので、これを購読し、コンソールに出力するタスクを非同期でバックグラウンド実行してください。通知される内容はUTF-8です。
また、サービス0xFFF0の、キャラクタリスティック0xFFF2がWrite, Write without Response属性を持っていますので、キーボード入力してEnterキーを押すまでの内容を送信してください。送信内容には改行コードを含めてください。入力の受付と送信はセットで繰り返し行います。
```

```
pythonでbleakを使って、"Carista"という名前のデバイスを探して接続してください。
その後、サービス0xFFF0の、キャラクタリスティック0xFFF1がIndicate, Notify属性を持っていますので購読してください。これが受信ポートです。
また、サービス0xFFF0の、キャラクタリスティック0xFFF2がWrite, Write without Response属性を持っています。送信内容には改行コード(0x0D)を含めてください。これが送信ポートです。
データはUTF-8です。

このデバイスはELM327です。エンジンの回転数を1秒おきに取得して表示するプログラムを作成してください。
```