NIKKEI TECHNOLOGY AND CAREER

喫茶店でも日経電子版を読みたい

はじめまして、今年の 9 月に入社した井手です。 NIKKEI Advent Calendar 2021 17 日目を担当します。 今日は Web Bluetooth の仕様を Bluetooth の説明を交えながら解説し、Web Bluetooth を日経でどう使えそうかを考えてみます。

紙の新聞について

皆さんは紙の新聞を読みますか?私は読みます。日本経済新聞社の福利厚生の1つには日本経済新聞の購読費補助があり、私は日経 W プランを購読しています。

最近は紙の新聞に触れる度に、新聞が紙である意義を考えるようになりました。新聞には国民の知る権利を支える大切な役割がありますが、紙媒体にしか寄与できない側面があると思います。例えば紙媒体の新聞は朝刊・夕刊単体で購入でき、欲しい情報を安価に購入できます。また、スーパー銭湯や喫茶店といった場所に置いてある新聞を回し読むことで、購入せずとも情報にアクセスできます。特に回し読みができることは、公共のスペースなどに赴けば情報にアクセスできるという安心感を担保してくれています。

しかし時代の流れからか休刊や廃刊になる紙媒体の新聞も増えており、また特に昨今の感染症対策により回し読みできる新聞が共有スペースから撤去されることで、回し読みが難しくなっています。そこで、この回し読みをデジタルで実現するために Bluetooth と Web Bluetooth を使った解決策を考えてみます。

喫茶店にいることの証明

喫茶店での日経電子版回し読みを実現するために、喫茶店にいるユーザーが日経電子版の会員用記事へアクセスできる仕組みを考えます。喫茶店にいるユーザーに対して日経電子版の会員用記事をアンロックしたいので、そのユーザーが喫茶店にいることの証明が必要です。その証明のために Bluetooth を使います。一部のモダンブラウザには Web Bluetooth API が備わっており、Bluetooth 機器への接続が可能です。そこで喫茶店に Bluetooth の発信デバイスを設置し、日経電子版の Web サイトから喫茶店にある Bluetooth を検出できたら喫茶店にいると判定して、一時的に会員用記事を読めるようにします。

そのためにサンプル実装として、この記事では Bluetooth による鍵解除に対応している簡易的なメディアサイトと Bluetooth 発信デバイスを開発します。(※これらは日本経済新聞社のプロダクトとは関係がなく、メディアサイトの最小構成でしかないことをご留意ください。)

demo lock

Bluetooth について

Web Bluetooth API を使った開発を始める前に Bluetooth について解説します。 Web Bluetooth API はまだ試験的な機能であり、解説も他の API と比較すると充実していません。

Can I Use

(https://caniuse.com/?search=bluetooth)

また、Bluetooth そのものについての知識がない現行のドキュメントを読むことが難しいので、Bluetooth そのものの解説から始めたいです。

なお、参考にした資料は次の通りです。特に Bluetooth Core Specification からは一部の図を引用しています。

  • Bluetooth Core Specification
    • Bluetooth そのものの仕様
  • Bluetooth Low Energy をはじめよう (O'Reilly)
    • 体系的な教科書として有名な本、その和訳
    • 和訳した用語はこの本に参考しました
  • Arduino IDE と ESP32 の Example
    • Arduino IDE からサンプルコードを出力できるので、それを読むとどのようにして通信が確立するかのイメージがソースコードから掴めます。

BLE デバイスのライフサイクル

Bluetooth デバイスは Advertising -> Scan -> Connect という段階を得て接続がされます。具体的なプロセスをスマートフォンとワイヤレスイヤホンを例に考えてみましょう。

Advertising

まずワイヤレスイヤホンから自身が接続可能であることを発信します。ワイヤレスイヤホンを使ったことがある方は、スマートフォンと接続するためにイヤホン側にあるボタンを押した経験があると思いますが、あの行為が Advertise と呼ばれ Advertising Packet と呼ばれるものが送信されています。

Scan

その Advertising Packet の検索・受取を Scan と呼びます。スマートフォンから接続したい Bluetooth 端末を探す機能を使うことが Scan に該当します。

Connect

Scan すると接続可能なデバイス一覧がスマートフォンに表示されますが、これは Advertising Packet を送っている端末の一覧とも言えます。この一覧から接続したい端末を選ぶことで接続できます。

Central と Peripheral

先ほどの例は スマートフォンとワイヤレスイヤホンでしたが、これらを一般化してみましょう。 Bluetooth の仕様では、今回で言うスマートフォンは Central と呼ばれ、ワイヤレスイヤホンは Peripheral と呼ばれます。 一般的に一つの Central に複数の Peripheral が接続できます。 この 1:N 関係は PC(Central)がワイヤレスイヤホン、マウス、キーボード、マイクなど(Peripheral)と同時に Bluetooth 接続できていることを思い出すとイメージしやすいです。 反対に一つのワイヤレスイヤホンに対して複数の PC やスマートフォンから同時に接続できないことからも、この 1:N の関係の想像がつくと思います。

実装しなければいけない規格: GAP

この接続までの流れは規格として定められています。

Profile とは

Bluetooth の規格にはプロトコル以外にプロファイルという言葉がよく登場します。 代表的なものは GAP(Generic Access Profile) と GATT(Generic Attribute Profile) です。 この Profile という言葉はプロトコルを使う方法と説明されています。 そのため、 Profile の説明に入る前にどのようなプロトコルが使われているのかを見てみましょう。

接続のためのプロトコル

Bluetooth 通信で使われるパケットは、Preamble, Access Address, PDU Header, PDU Payload, CRC と呼ばれるセクションから成り立ちます。

このうち Advertising に使われるのは、PDU Header と PDU Payload です。 PDU Header には PDU Type と呼ばれるパケットの種別が含まれ、接続やスキャン可否の判定に利用されます。

PDU Header

(https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=478726)

PDU Header に続く PDU Payload には Advertising 時に使われる情報を入れられます。 これは AdvData と呼ばれ、AD Structure と呼ばれるフォーマットに従っています。

AD Structure は AdvData の長さ + AD タイプ + AD データ として作られます。 Web のプロトコルに馴染みがある方によっては Payload の長さ + Payload の形式のデータ構造に馴染みがあると思いますが、同じようなものです。

開始位置中身
1 byteAD ストラクチャの長さ(byte)
2 byteAD タイプ
3 byte 以降AD データ

AD タイプには

  • Flags: 端末の情報などの接続情報
  • Names: デバイスの名前
  • TX Power Level: 通信の強度
  • Manufacturer specific data: デバイスが提供するカスタムデータ。最初の 2byte はメーカーによって予約されている。
  • など

が入ります。

Advertising 時にはこれらの AD Structure が必要な AD タイプの分だけ連なって送られてきます。

adtype

(https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=478726)

ここに含まれる情報は、ユーザーがスキャンを進行させるときに使われており、たとえば接続時にどのデバイスに接続するかといった表示(デバイス名や通信強度の表示)に使われています。

GAP はどのようにして接続するか

GAP はこれまで具体例で説明した Advertising 〜 Connect の流れを一般化したもので、次のことを規定し、先ほど紹介したパケットを使って実現します。

  • デバイスの役割
    • 1:1 の通信における Peripheral, Central
    • 1:N の通信における Broadcaster, Observer
  • デバイスの状態
    • 検索の可否
    • 接続の可否
  • デバイスを接続する手順
    • allow list を使った自動接続
    • スキャンを通じた未知のデバイスとの接続

具体的な advertising 方法ですが、Peripheral は宛先を指定して advertising を行うわけでなく、無条件にパケットを定期的に高頻度で流します。 それをたまたま Central が拾い、その Central が接続を許可を申請して接続がされます。 そのため皆さんも自分の端末に接続しようとして Bluetooth の一覧を開くと関係ないデバイスを見たことがあると思いますが、その理由は Peripheral が無条件に Advertising をしているためです。

接続後のデータ通信

ここまででデバイスを接続するまでの流れを解説しました。 次に接続が完了した後のデータ通信について見ていきます。

ATT に乗っ取った通信

ATT とはサーバーとクライアントのようなモデルを提供するアトリビュート・プロトコルです。 サーバーが持つ Attribute と呼ばれるものに対して Client がデータを読み書きすることで Central, Peripheral の通信を実現します。

Attribute のデータ構造

この Attribute はハンドル・タイプ・値・パーミッションから成り立つデータ構造です。

  • ハンドル
    • GATT サーバー内で全 Attribute にユニークな ID
  • タイプ(UUID)
    • Service や characteristic などの attribute を識別するために使用される固有の番号。心拍数通知サービスなど汎用的なものはあらかじめ Bluetooth SIG によって番号が決められています。
    • その Attribute が持つ値
  • パーミッション
    • アクセスパーミッション(読み書き可能か)、暗号化の要否、認可の要否についてのメタデータ
ATT

(https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=478726)

BLE が実装しなければいけない規格: GATT

さてここまでで Bluetooth の中核というべき GATT を説明する準備が整いました。 ここでは多くの Bluetooth 機器が則っているデータ通信手順である GATT を見ていきます。

GATT とは

GATT は汎用アトリビュートプロファイルと呼ばれ、ATT を利用したデータ通信手順であり、ATT に階層構造を持たせ各 Attribute を Service という単位でまとめあげます。 各 Service に含まれる Attribute は Characteristic (特性)と呼ばれています。

GATT

心拍数モニタリングデバイスを例に挙げると、心拍数を測定する Service が、それぞれ心拍や手や足といった計測位置のデータをそれぞれの Characteristic に持っていると考えられます。

Service

GATT サーバーにある Attribute を意味ある区分でまとめたものを Service と呼びます。

GATT ではあらかじめ定義されている Service もあり、例えば 前述の心拍数測定サービス や他にも オーディオのインプットサービスなどがあります。 GATT は「汎用」と付くだけあって、使いまわせる枠組みやフレームワークも提供しています。 どのようなサービスが定義されているかは、https://www.bluetooth.com/specifications/specs/ を参照してください。

Service に含まれる Attribute はサービス宣言 Attribute と呼ばれ、後述する characteristic と呼ばれるデータ群に対するラベルと言えます。

characteristic

characteristic が Bluetooth を使ってやりとりしたいデータの Key と Value です。 characteristic は「特性宣言」と「特性値」という 2 つの attribute から成り立ちます。 特性宣言 attribute も attribute なので ハンドル、タイプ、パーミッション、値で構成されています。 特性宣言の値には特性値ハンドルを含められ、特性値の attribute のハンドルが含まれています。 つまり特性宣言 attribute と特性値 attribute が紐づくような工夫がされています。

そして特性値 attribute の値セクションには実データの値が含まれます。この値こそが Central <-> Peripheral 間でやりとりしたいデータそのものです。

そのため特性宣言が Key, 特性値が Value と言えます。 分かりやすい Key 名ではないですが、汎用的な Service を使っていればタイプから Key の名前を調べられたり、独自定義の Service を作っている時は GATT Server を作成したときに該当 Service の UUID を設定してその値を Key として使います。 また 特性値 attribute の後ろには 特性 descriptor という メタデータを表す attribute も追加できます。

characteristic

Bluetooth の復習

さまざまな言葉が出てきて混乱したと思いますので、一度整理しましょう。

プロファイルの復習

Bluetooth は GAP に則ってコネクションを行います。GAP ではデバイスは Central と Peripheral に分類し、Peripheral からの Advertising に Central が応えることで接続する方法を定義しています。

接続ができると GATT に則って通信を行います。 GATT は ATT をベースにした規格で、ATT がデバイス間でサーバー・クライアントモデルを規定し、通信はサーバーにある Attribute をクライアントが読み書きすることで行われます。

GATT はこの ATT に階層構造・グループを規定したもので、各 Attribute をサービスという単位でまとめあげ、その中に characteristic と呼ばれる Attribute を複数配置します。このフレームに各ベンダーが従うことで、Bluetooth対応端末同士で通信が可能になります。

用語の復習

  • GAP: 汎用アクセスプロファイル。デバイス同士の接続方法を規定する。
  • ATT: アトリビュートプロトコル。 Attribute というデータ構造を使った、サーバー・クライアント式の通信プロトコル
  • GATT: Service, Characteristic という単位で Attribute を階層化した ATT
  • Attribute: ATT, GATT で使われるデータ構造、もしくはフォーマット
  • Service: GATT サーバー内にある、概念的に共通な Attribute のグループ。Service に対してはラベルとしての Attribute が付随しており、外部からはその Attribute を通して通信できる。
  • Characteristic: Service 内にある Attribute であり、主にキーとデータが入っている。多くの場合でユーザーが読み書きする対象。

実機で検証する

それではイメージを固めるためにも実際に動かしてみましょう。

デバイスには ESP32 を利用

Bluetooth 接続できる機器として ESP32 を用意しました。

ESP32

FYI: https://ja.wikipedia.org/wiki/ESP32

このマイコンの素晴らしさは贅沢なところでして、Bluetooth だけでなく、WiFi モジュールも持ち、USB Type-C で接続できます。 また Arduino IDE に対応しており、Arduino IDE で開発できます。 Arduino は教育目的で作られた安価かつ簡単に動かせ高機能なマイクロコントローラーです。

FYI: https://www.arduino.cc/

ESP32 は Type-C ケーブルと Arduino IDE があれば開発できるため、開発環境の構築がとても楽です。 Arduino IDE 側のボードマネージャで ESP32 を追加すれば、たくさんの Example コードを使うことができ、勉強が捗ります。

Arduino

さて、Arduino の Example を使いながら Bluetooth の通信を実際に試しつつ、Central, Peripheral の動きを確かめてみましょう。

Peripheral のデータをセントラルから読み書きする

Example のソースコードをそのまま使って実験します。

ただし紙面の都合上コメントやロガーは削除しています。 また、シリアル通信のデータ転送レートは環境に合うように調整しています。

このような Peripheral を用意します。

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

class MyCallbacks: public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) {
      std::string value = pCharacteristic->getValue();
      if (value.length() > 0) {
        Serial.println("*********");
        Serial.print("New value: ");
        for (int i = 0; i < value.length(); i++)
          Serial.print(value[i]);
        Serial.println();
        Serial.println("*********");
      }
    }
};

void setup() {
  Serial.begin(9600);
  BLEDevice::init("MyESP32");
  BLEServer *pServer = BLEDevice::createServer();
  BLEService *pService = pServer->createService(SERVICE_UUID);
  BLECharacteristic *pCharacteristic = pService->createCharacteristic(
                                         CHARACTERISTIC_UUID,
                                         BLECharacteristic::PROPERTY_READ |
                                         BLECharacteristic::PROPERTY_WRITE
                                       );
  pCharacteristic->setCallbacks(new MyCallbacks());
  pCharacteristic->setValue("Hello World");
  pService->start();
  BLEAdvertising *pAdvertising = pServer->getAdvertising();
  pAdvertising->start();
}

void loop() {
  delay(2000);
}

上のコードは、MyESP32 というデバイス名を設定し、そこに GATT Server を立てて、Service と Characteristic を定義しました。

さて、Central を Client としてアクセスするわけですが、Central には既製品のアプリを使います。 Central を自分で実装しようとすると、スキャン、接続、attribute の検索、書き込みと色々工程を踏まないといけなく、解説に多くの紙面を割くため省略します。 もし Central の実装が気になる方は公式のサンプルコードがありますので、興味がある方はご覧ください。

FYI: https://github.com/espressif/arduino-esp32/blob/master/libraries/BLE/examples/BLE_client/BLE_client.ino

さて、さまざまな Bluetooth の Central, Peripheral に成りすませるデバッグアプリがありますが、今回は BLE Scanner 4.0 を使います。 このアプリで Bluetooth を Scan し、characteristic への読み書きを行います。

まず、Advertising Packet を検索します。

スキャン

周りにたくさん Packet が飛んでいますが、自分が送っている MyESP32 というものに繋いでみましょう。 すると デバイスの情報や持っているサービスの情報が表示されます。

サービス

次に該当のサービスにアクセスしてみます。 すると characteristic を read できました。 元々書き込んである "Hello World" という文字を読み込めています。

Read

では次に characteristic に値を書き込んでみます。 値はアプリから書き込めます。

Write

書き込んだログを見てみましょう。

serial

きちんと書き込めました。

ここまでで、GAP, GATT に則った実装が期待した動作になることが確認できました。

Web Bluetooth について

では次に Web Bluetooth API についてみていきます。 ここでの目標は先に紹介した iPhone アプリがしていたことを Web ブラウザ上でできるようになることです。

まず、Web Bluetooth API では何ができるのかを確認するために仕様に目を通してみます。

概要

Web Bluetooth API の仕様はこちらです。

FYI: https://webbluetoothcg.github.io/web-bluetooth/

ここには、

The first version of this specification allows web pages, running on a UA in the Central role, to connect to GATT Servers over either a BR/EDR or LE connection.

とあることから、Web Bluetooth API を使うと Central として振る舞え、GATT サーバーへ接続できることが分かります。

GAP

さて global に定義されているオブジェクトから見ていきます。

[Exposed=Window, SecureContext]
interface Bluetooth : EventTarget {
  Promise<boolean> getAvailability();
  attribute EventHandler onavailabilitychanged;
  [SameObject]
  readonly attribute BluetoothDevice? referringDevice;
  Promise<sequence<BluetoothDevice>> getDevices();
  Promise<BluetoothDevice> requestDevice(optional RequestDeviceOptions options = {});
};

getDevices という関数が生えているので、ここからデバイスへの接続ができそうです。 その デバイスの定義はこうなっています。

[Exposed=Window, SecureContext]
interface BluetoothDevice : EventTarget {
  readonly attribute DOMString id;
  readonly attribute DOMString? name;
  readonly attribute BluetoothRemoteGATTServer? gatt;

  Promise<undefined> watchAdvertisements(
      optional WatchAdvertisementsOptions options = {});
  readonly attribute boolean watchingAdvertisements;
};

GAP の手順をユーザーが実装するのではなく直接 GATT Server にアクセスできるようです。

仕様書の初めに

The first version of this specification allows web pages, running on a UA in the Central role, to connect to GATT Servers over either a BR/EDR or LE connection.

とある通り、GATT Server へのアクセサ(つまり GATT Client)として使われることが前提の機能であるため、このような実装になっているのだと思います。

GATT

では、GATT に関する仕様を確認してみましょう。 Web Bluetooth API は GATT に対応した API です。

GATTServer へのアクセスは BluetoothRemoteGATTServer クラスを通して実現できます。 それは、

[Exposed=Window, SecureContext]
interface BluetoothRemoteGATTServer {
  [SameObject]
  readonly attribute BluetoothDevice device;
  readonly attribute boolean connected;
  Promise<BluetoothRemoteGATTServer> connect();
  undefined disconnect();
  Promise<BluetoothRemoteGATTService> getPrimaryService(BluetoothServiceUUID service);
  Promise<sequence<BluetoothRemoteGATTService>>
    getPrimaryServices(optional BluetoothServiceUUID service);
};

という作りになっています。getPrimaryService があるので、ここから GATTService を取得できそうです。

GATTService は BluetoothRemoteGATTService として実装されていて、

[Exposed=Window, SecureContext]
interface BluetoothRemoteGATTService : EventTarget {
  [SameObject]
  readonly attribute BluetoothDevice device;
  readonly attribute UUID uuid;
  readonly attribute boolean isPrimary;
  Promise<BluetoothRemoteGATTCharacteristic>
    getCharacteristic(BluetoothCharacteristicUUID characteristic);
  Promise<sequence<BluetoothRemoteGATTCharacteristic>>
    getCharacteristics(optional BluetoothCharacteristicUUID characteristic);
  Promise<BluetoothRemoteGATTService>
    getIncludedService(BluetoothServiceUUID service);
  Promise<sequence<BluetoothRemoteGATTService>>
    getIncludedServices(optional BluetoothServiceUUID service);
};
BluetoothRemoteGATTService includes CharacteristicEventHandlers;
BluetoothRemoteGATTService includes ServiceEventHandlers;

という作りになっています。 characteristic は service の中にあることを思い出してください。 getCharacteristic があるので、ここから GATTCharacteristic を取得できそうです。

GATTCharacteristic は BluetoothRemoteGATTCharacteristic として実装されていて、

[Exposed=Window, SecureContext]
interface BluetoothRemoteGATTCharacteristic : EventTarget {
  [SameObject]
  readonly attribute BluetoothRemoteGATTService service;
  readonly attribute UUID uuid;
  readonly attribute BluetoothCharacteristicProperties properties;
  readonly attribute DataView? value;
  Promise<BluetoothRemoteGATTDescriptor> getDescriptor(BluetoothDescriptorUUID descriptor);
  Promise<sequence<BluetoothRemoteGATTDescriptor>>
    getDescriptors(optional BluetoothDescriptorUUID descriptor);
  Promise<DataView> readValue();
  Promise<undefined> writeValue(BufferSource value);
  Promise<undefined> writeValueWithResponse(BufferSource value);
  Promise<undefined> writeValueWithoutResponse(BufferSource value);
  Promise<BluetoothRemoteGATTCharacteristic> startNotifications();
  Promise<BluetoothRemoteGATTCharacteristic> stopNotifications();
};
BluetoothRemoteGATTCharacteristic includes CharacteristicEventHandlers;

という作りになっています。 ここからデータ(attribute 値)を取得できたり、書き込み処理、characteristic に続く別パケットとして送られる attribute である descriptor も取得できます。

Bluetooth の世界ではやり取りするデータは全てが attribute という単位の packet 表現で送られてきていますが、Web Bluetooth API ではそれが意味のある単位(server, service, characteristic)のクラスとして抽象化されています。 さらに型定義ファイルも提供されているので、TypeScript 環境下だと補完や inspect が充実しており扱いやすく、Bluetooth Client の勉強にも良いです。

実装例

では Web Bluetooth API は具体的にどう使われるのか見ていきましょう。 https://web.dev/bluetooth/ によると次のような呼び出しができるようです。

navigator.bluetooth
  .requestDevice({ filters: [{ services: ["battery_service"] }] })
  .then((device) => device.gatt.connect())
  .then((server) => {
    // Getting Battery Service…
    return server.getPrimaryService("battery_service");
  })
  .then((service) => {
    // Getting Battery Level Characteristic…
    return service.getCharacteristic("battery_level");
  })
  .then((characteristic) => {
    // Reading Battery Level…
    return characteristic.readValue();
  })
  .then((value) => {
    console.log(`Battery percentage is ${value.getUint8(0)}`);
  })
  .catch((error) => {
    console.error(error);
  });

きっとここまでの説明を踏まえると、各行で何をしているのかが分かるでしょう。 デバイスを検索し、接続し、サービスを検索し、その中の characteristic を取得し、その中から値を読み出しています。

実装してみよう

ではこれらの知識を踏まえて Bluetooth でアンロックできる新聞を作っていきます。

Peripheral

まず、Peripheral となるデバイスを "BLE-SHINBUN-SERVER" という名前で作ります。 これを Central からの scan に引っ掛かるように設定します。

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

void sendMessage() {
    char str[30];
    sprintf(str, "token");
    Serial.println(str);
    pCharacteristic->setValue(str);
    pCharacteristic->notify();
}

void setup() {
  Serial.begin(115200);
  Serial.println("Starting BLE work!");

  BLEDevice::init("BLE-SHINBUN-SERVER");
  BLEServer *pServer = BLEDevice::createServer();
  BLEService *pService = pServer->createService(SERVICE_UUID);
  BLECharacteristic *pCharacteristic = pService->createCharacteristic(
                                         CHARACTERISTIC_UUID,
                                         BLECharacteristic::PROPERTY_READ |
                                         BLECharacteristic::PROPERTY_WRITE
                                       );

  pService->start();
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);  // functions that help with iPhone connections issue
  pAdvertising->setMinPreferred(0x12);
  BLEDevice::startAdvertising();
}

void loop() {
  delay(2000);
}

行っていることとしては、

  • BLE-SHINBUN-SERVER という名前で peripheral を定義
  • GATT サーバーを立てる
    • Service の設定
    • Characteristic の設定

です。

Central

Peripheral に立てた GATT サーバーへのアクセスを、Web Bluetooth API で行います。

const bluetooth = window.navigator["bluetooth"];
bluetooth
  .requestDevice({
    acceptAllDevices: true,
  })
  .then((ble) => {
    // no op
  });

ここでは、BLE-SHINBUN-SERVER があることを確かめています。 このとき、デバイスの存在を確認できたらいいので接続はしません。

Application

さて、これらのコードをアプリケーションコードへ埋め込んでいきます。

設計としては、クライアントが BLE デバイスを検知したら、その名前をトークンとして認証エンドポイントを叩き、鍵付き記事を読むためのクッキーを付与します。 そしてそのクッキーを持って記事ページにアクセスし、認証されていれば記事が読めると言う風にします。 これはメディアとしては SEO を担保する必要があるので SSR する必要があると言うことと、有料会員だけ記事が読めるようにするためには、SSR 時に会員か否かを制御する必要があるからです。

サンプルコードでは SSR 時をサポートできるよう、NextJS を利用しました。

Client への Peripheral Code の埋め込み

Web Bluetooth API をクライアントアプリケーションから呼び出します。

const Index: VFC<Props> = (props) => {
  const setter = useSetToken();
  const handleScan = () => {
    if ("bluetooth" in window.navigator) {
      const bluetooth = window.navigator["bluetooth"];
      bluetooth
        .requestDevice({
          acceptAllDevices: true,
        })
        .then((ble) => {
          fetch("/api/auth", {
            method: "POST",
            body: JSON.stringify({
              token: ble.name,
            }),
          });
        });
    }
  };
  return (
    <div>
      <h1>shinbun</h1>
      <button onClick={handleScan}>scan ble</button>
      <div>
        {props.articles.map((article) => (
          <Link key={article.id} href={`articles/${article.id}`}>
            <p>{article.head}</p>
          </Link>
        ))}
      </div>
    </div>
  );
};

ここで注意したいのは、Web Bluetooth API はユーザーのインタラクションがないと起動しない点です。 つまり useEffect(()=>{},[]) に入れて発火させるといったことはできません。

ここではユーザーがボタンを押したときに、BLE-SHINBUN-SERVER があるかを確認し、あれば認証エンドポイントを叩くようにしています。

認証

BLE-SHINBUN-SERVER というトークンを付けてのアクセスであれば、"secret-value" というクッキーをつけるエンドポイントを用意します。

import { serialize } from "cookie";
import type { NextApiRequest, NextApiResponse } from "next";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const body = JSON.parse(req.body)["token"];
  if (body === "BLE-SHINBUN-SERVER") {
    const value = serialize("ble_authed", "secret-value", {
      httpOnly: true,
      path: "/",
    });
    res.setHeader("Set-Cookie", value);
    res.status(204).send("");
  }
};

こうすることで近くに Bluetooth デバイスがある人しか叩けないエンドポイントが実現できました。

Server での Paywall 制御

最後に、クッキーを持っている人と持っていない人でページを出し分けます。 これは SSR 時に cookie を見れば良いです。

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  const aid = ctx.params["aid"];
  if (typeof aid !== "string") throw new Error("invalid");

  const cookie = ctx.req.cookies["ble_authed"];
  if (cookie !== "secret-value") {
    return {
      props: {
        id: parseInt(aid),
        head: "タイトル",
        body: "記事本文記事本文...続きを読むには会員登録",
      },
    };
  }

  return {
    props: {
      id: parseInt(aid),
      head: "タイトル",
      body:
        "記事本文記事本文記事本文記事本文記事本文記事本文記事本文記事本文記事本文",
    },
  };
};

と、このように実装することができました。

demo

考慮すべき問題

Bluetooth の有効距離が足りるのか

これに関しては不安が残ります。一般的には 5m までなら問題なく届くとは言われていますが、実際には周りの通信状況にもよると思います。 電子機器が密集している場所であると繋がらないこともあるかもしれません。

ただその場合はユーザーに Bluetooth 発信源まで近づいて貰うことでの解決を考えています。 共有される新聞も新聞コーナーに取りに行く訳なので、むしろユーザーに近づいてもらうのは体験としては紙の新聞を再現できていると思います。

Safari への対応

実は Safari では Web Bluetooth API がまだ使えず、将来的にサポートされるかも分かりません。 そのため iOS ユーザーに対して Bluetooth を使った機能を提供するためにはネイティブアプリにこのような機能を実装しなければいけません。 幸い iOS には Core Bluetooth API が備わっており、Bluetooth 接続は可能な上 Web Bluetooth API より歴史は長いのでノウハウもたくさんあります。 ただ、ちゃぶ台を返すのであればiOS のカバーを考えるのであれば Airdrop を使って新聞を配布しても解決できそうと思いました。

トークンの流出

クッキーの付与に必要なトークンが漏れると、喫茶店にいなくてもアクセスが可能となります。

対策としては

  • ESP32 は WiFi が使えるので、ネットワーク越しに GATT サーバー内にワンタイムトークンを配置する
  • 定期的な BLE スキャンを要求する

などが考えられ手間はかかりますが、Webサーバーと通信できる Bluetooth 端末ならではの対策のやりようはあります。

プライバシー

Bluetooth 端末が発行しているトークンは、どこの喫茶店のものであるかを日経は原理的に知れるため、その気になればユーザーがどの喫茶店にいるかを把握できてしまいます。 その結果、ユーザーの生活圏や日常ルーティンを把握できる可能性があり、プライバシーを保護する方法を考えなければいけません。

まとめ

Web Bluetooth を始め、Web の様々な API によってリアルとの接続ができる場面が増えています。 リアルでの行動を元に Web コンテンツをアンロックする仕組みは色々と応用が効きそうなので、引き続き様々な API で遊びたいと思います。

さいごに

Web チームには Web 技術そのものが好きなメンバーが集っています。 Web が好きという方はぜひJobsからご連絡ください!

井手 優太
ENGINEER井手 優太

Entry

各種エントリーはこちらから

キャリア採用
Entry
新卒採用
Entry
短期インターン
Entry