これはドリコム Advent Calendar 2020 の14日目です。

はじめに

こんにちは。
enzaプラットフォーム事業本部のフロントエンドエンジニアのsho-hey-heyです。
長年、Webサービスに携わってきてプロダクトで Bluetooth を利用したことがなかったのでこれを機会に調べてみようかと思います!
Drecom でもぜひ Web BluetoothAPI を利用したサービスをリリースしたいですね!

Bluetooth とは

この記事を読んでいる人であれば誰しもが使ったことがあるかと思います。近距離無線通信の規格のひとつで、スマートフォンやオーディオ機器を始めとしたいろいろなものに組み込まれていますね。
Bluetooth は Bluetooth SIG (= Bluetooth Special Interest Group) という標準化団体が、開発、テクノロジと商標のメーカーへのライセンス提供を行っています。
そんなBluetoothはバージョニングされていて、v1.0 〜 3.0 までは Classic、v4.0 〜 5.2 は BLE と呼ばれています。ここでは BLE を利用したはなしをしていきます。

BLE とは

BLE (= Bluetooth Low Energy) は bluetooth の規格の一部で、その名の通り低電力消費・低コストに特化したものです。引き換えに通信速度が遅いですが、バージョンが上がるごとに改善されているようです。また、電波到達距離も伸びているようです。

Web Bluetooth APIを使ったときのおおよその流れ

Web Bluetooth API とはブラウザから BLE を操作するための API です。(2020/12/10) 現在は Chrome, Opera, Edge などのブラウザで利用可能になっています。
そんな Web Bluetooth API を利用して、PC (Browser) からイヤホン (Device) に接続するときを例にあげてみます。
  1. Browser からイヤホンの Device 情報を取得する
  2. Device の GATT Server に接続する
  3. GATT Server から Service を取得する
  4. Service から Characteristic を取得する
  5. Characteristic を利用する
    1. Read: 値を読み込む
    2. Write: 値を書き込む
    3. Notify: 通知を受け取る
おそらくちんぷんかんぷんだと思います。僕もわかりません。

必要な知識

ATT

GATT を紹介する前に ATT の説明が不可欠なので軽く説明をしようと思います。ATT は、 Attribute Protocol の略です。Handle、Type、Value、Permission の4つの値で構成されてます。
  • Handle: 2byte で表すインデックス
  • Type: 16byte でどんな役割のものなのかを表す
  • Value: その属性の値。アプリケーションレイヤーで利用され、512byte までで表すことができる
  • Permission: 権限を表す(Read, Write, Notify など)

GATT

GATT は、Generic Attribute Profile の略で先ほど説明した ATT を用いて、データの構造化とアプリケーション間でのやり取りを定義するものです。基本的に、ServiceCharacteristic で構成されています。GATT には、1つ以上の Service と 0個以上の Characteristic が含まれるようになっています。Bluetooth SIG によって定義されているもので、 Blood Pressure Profile というものがあります。こちらは、血圧測定値やその他のデータを取得するための Service と Characteristic で構成されています。

Service

Service とはある機器に備え付けられている機能の1つを表すものです。Blood Pressure Profile でいうと、血圧を測る機能 (Blood Pressure Service) とデバイス情報を取得する機能 (Device Information Service) のことを言います。

Characteristic

Characteristic は値の読み・書き・通知を行うものです。Blood Pressure Service でいうと 血圧測定を通知するための値 (Blood Pressure Measurement) や 測定中のカフ圧を通知するための値 (Intermediate Cuff Pressure) などアプリケーションで利用される情報が入っています。

上記例にあげたもの以外にも、定義されているProfileはこちらから確認することができます。

改めて、最初に記載した手順で接続から操作まで行うと以下の図のような流れになります。
次は、この流れの実装を見てみましょう。

実装

if ("bluetooth" in window.navigator) {
    const bluetooth = window.navigator["bluetooth"] as any;
    // 1. device 情報取得
    const device = await bluetooth.requestDevice({
        acceptAllDevices: true,
    });

    // 2. GATT Server に接続
    const server = await device.gatt.connect();

    // 3. GATT Server から Service を取得する
    const service = await server.getPrimaryService("********-****-****-****-************");

    // 4. Service から Characteristic を取得する
    const characteristic = await service.getCharacteristic("********-****-****-****-************");

    // 5. Characteristic を利用する
    // 5.1. READ
    if(characteristic.properties.read) {
        console.log(await characteristic.readValue());
    }
    // 5.2. WRITE
    if(characteristic.properties.write) {
        characteristic.writeValue(value);
    }
    // 5.3. NOTIFY
    if(characteristic.properties.notify) {
        const handler = (event: any) => {
            console.log(event.target.value);
        }
        characteristic.addEventListener("characteristicvaluechanged", handler);
        characteristic.startNotifications();
    }
}
だいぶ雑な感じですが、おおよその操作はこれでできるかと思います。
こういった DEMO はたくさんあるのですが、いざ一から自分でいじってみようとすると程よいサンプルがあまり見つかりませんでした。そんなわけで、この記事を書いてるときの検証用に使ったコードを紹介しようと思います。

検証コード

検証に利用したソースコードをはこちらにあがっています。

Service & Characteristic 参照

先程の接続するためのソースコードを見ると、適当なデバイスを接続しようとしたときに ServiceUUID や CharacteristicUUID が必要になります。そんな事言われても手持ちのデバイスの UUID とか突然わからないですよね。確認できるようにしましょう。

動作イメージ

ソースコード

取得部分のみ抜粋して記載します。
const SERVICE_UUID_START = 0x1800;
const SERVICE_UUID_END = 0xffff;

if ("bluetooth" in window.navigator) {
    // すべての device 取得
    const device = await (window.navigator["bluetooth"] as any).requestDevice({
        acceptAllDevices: true,
        // 今までアクセスしたことのない service を参照するには
        // ここで ServiceUUID を指定しなければいけない
        optionalServices: Array(SERVICE_UUID_END - SERVICE_UUID_START)
            .fill(0)
            .map((_, i) => (SERVICE_UUID_START + i)),
    });
    // GATT Server に接続
    const server = await device.gatt.connect();
    // GATT Server から Service の情報をすべて取得
    const services = await server.getPrimaryServices();
    const dataList = await Promise.all(services.map(async (v: any)=> ({
        service: v,
        // Service から Characteristic の情報をすべて取得
        characteristics: await v.getCharacteristics(),
    })));
    console.log(dataList);
}
あとは、取得した service や characteristic のプロパティに UUID があるので参照して表示しています。ただし、optionalServices 部分で説明しているように参照したことのない service にはアクセスできないので実際は全てを出すことはできないようです。 (ダメ元で optionalServices に 0x000000000xffffffff (長さ 4,294,967,295 の配列)を指定してたくさん表示させてみようかと思ったんですがブラウザが固まりました。)

Characteristic を操作する

続いて Characteristic の操作を行うものを作ります。操作は READ、 WRITE、NOTIFY になります。

動作イメージ

ソースコード

主要処理を一部抜粋して記載します。
/**
 * 変換したデータを複数の形式で取得する
 */
const getValueList = (value: DataView) => {
    const vl = {} as {[key: string]: string};
    const buffer = value.buffer;
    // UTF-8 で変換する
    const decoder = new TextDecoder("utf-8");
    vl["string"] = decoder.decode(value);
    vl["uint8"] = new Uint8Array(buffer).join(",");
    vl["uint16"] = new Uint16Array(buffer).join(",");
    vl["uint32"] = new Uint32Array(buffer).join(",");
    vl["int8"] = new Uint8Array(buffer).join(",");
    vl["int16"] = new Uint16Array(buffer).join(",");
    vl["int32"] = new Uint32Array(buffer).join(",");

    return vl;
}

/**
 * 読み込み
 */
const read = async () => {
    if(characteristic.properties.read) {
        try {
            // characteristic から value を読み込む
            const value = await characteristic.readValue();
            const vl = getValueList(value);
            console.log(vl);
        } catch (e) {
            console.log(e.message);
        }
    }
}

/**
 * 書き込み
 */
const write = () => {
    if(characteristic.properties.write) {
        const value = "書き込み";
        const encoder = new TextEncoder();
        // UTF-8 の文字列から Uint8Array に変換する
        const encodedValue = encoder.encode(value);
        // characteristic に value を書き込む
        characteristic.writeValue(encodedValue);
    }
}

/**
 * 通知
 */
const notify = async () => {
    if(characteristic.properties.notify) {
        // 変更検知ハンドラ
        const handler = (event: any) => {
            // 変更イベントから value を取得
            const value = event.target.value;
            const vl = getValueList(value);
            console.log(vl);
        }
        // 変更検知イベント登録
        characteristic.addEventListener("characteristicvaluechanged", handler)
        // 通知を開始する
        await characteristic.startNotifications();
    }
}
単純なものであれば、ここまでのことを知っていればなにか作れるかと思います。

まとめ

現在の機能で Bluetooth を用いた Webサービス を作るとブラウザと他のデバイスが必要になります。その場合、ブラウザの利点と言えるお手軽さがなくなってしまいます。その点を解消するためにはブラウザ間で接続できることが求められるかと思います。

mobile に関しては、今回 Android Chrome を利用して動作確認をしたのですが、この記事で紹介した機能であればPCと遜色なく動いていました。そのため、今の所 PC と mobile で機能としては同じものを提供できそうです。
しかし、mobile は iOS 上のブラウザで Bluetooth が対応しておらず Android に絞ってサービスを作る必要があります。

主機能として、本格的になにかのプロダクトに組み込むことは難しいと感じましたが、ちょっとした機能に組み込むことで with entertainment を提供することができそうですね!
ドリコムでは一緒に働くメンバーを募集しています! 募集一覧はコチラを御覧ください!