はじめに

Even Realities のスマートグラス Even G2 のアプリは、通常はスマートフォン側の Even Hub というアプリ上で動作させる Web アプリとして開発します(公式 SDK が提供するのはこの形だけです)。

ですが、Web アプリだとスマートフォンのネイティブ機能を使う場合に、幾らかの制約が出てきてしまいます。

今回、公式アプリを介さずに単独のネイティブから Even G2 に繋ぐことに成功しました。

本記事は、前後編になります。
前編では、BLE でつないで認証を通して、グラスに日本語テキストを表示するところまでを紹介します。

前提

1. Even G2 の BLE プロトコルが公開されていない

Even G2 の BLE プロトコルが公開されていません。
本記事は、ある既存の実装 (後述) を参考にして組み立てたものです。
今後ファームウェア更新で動かなくなる可能性があります。

2. 本記事のコードの扱い

本記事のコードは、実機でのみ動作確認が可能です。
実機が手元にない読者は、Even G2 特有のプロトコルの「癖」を知る読み物として楽しんでください!

3. Claude Code 利用

解析と実装には Claude Code を併走させました。

開発環境

項目バージョン
Flutter3.44.2
Dart3.12.2
BLE プラグインuniversal_ble 2.1.0
実機(iOS)iPhone 17 Pro
実機(Android)Galaxy S24 (Android 14)
Even G2 ファームウェア2.2.1.4

BLE とパーミッション

BLE (Bluetooth Low Energy) を使用するのでパーミッションの設定が必要です。

Android

マニフェストに BLUETOOTH_SCANBLUETOOTH_CONNECT を書きます。
スキャン開始前に UniversalBle.requestPermissions() を呼びます。

iOS

Info.plistNSBluetoothAlwaysUsageDescription を書きます。

Flutter パッケージ even_g2_ble

公式アプリを介さず BLE で直接接続する Flutter パッケージ even_g2_ble を社内向けに開発しました。
よってこの記事には、前後編を通して even_g2_ble からのコード抜粋があります。

even_g2_ble の概要を以下に示します。
コード上からは左右のアーム(眼鏡のつる)がそれぞれ独立した BLE デバイスとして見えます。
ゆえに、左右 2 本のアームの情報流入を、上位クラスが束ねる構造にしています。

開発アプリが G2Scanner でグラスを探し、得た G2Glasses を EvenG2Client に渡す。EvenG2Client は左右 2 本の G2SideConnection を持ち、その下で universal_ble を呼ぶ。メッセージ組み立ては messages.dart が担う、という関係を示した図
ファイル役割
公開 APIg2_scanner.darteven_g2_client.dart開発アプリ側が触る。G2Scanner でグラスを探し、EvenG2Client で接続と表示を行う
片アーム 1 本の接続g2_side_connection.dart左右それぞれの BLE 接続、サービス解決、notify 購読、書き込み
プロトコルmessages.dart一次資料のメッセージを組み立てる。記事中のコードでは msg という別名で参照している

Tip

以降の even_g2_ble  からのコード抜粋は、先頭に抜粋元のファイルパスを書いておく。

BLE プラグインを選ぶ

Flutter で BLE を扱うとき、まず名前が挙がるのは flutter_blue_plus ですよね。
ところがこのパッケージは v2.0.0 で独自ライセンスに変更されており、営利組織での利用は有償になっています。

候補ライセンス対応プラットフォーム状態
flutter_blue_plus 1.xBSD-3iOS、Android更新停止
flutter_blue_plus 2.x独自(営利利用は有償)iOS、Android、macOS、Windows、Linux、Web更新中
universal_bleBSD-3iOS、Android、macOS、Windows、Linux、Web更新中

よって universal_ble を選びました。
BSD-3 のまま更新が続いており、iOS と Android に加えて macOS と Windows でも動きます。

Note

私以外にも、直接BLE接続を試みている人たちがコミュニティにいた。

彼らの大半が flutter_blue_plus の 1.x 系 API を使用していた。
even_g2_ble の実装では、彼らのコードを universal_ble の API に読み替えながら移植することになった。

仕様書はどこにあるのか

MentraOS は複数のスマートグラスに対応したオープンソースの OS で、Even G2 の機能一式が MIT ライセンスで実装されています。
そこで docs.mentraglass.com を読み解きました。

ですが、掲載されていたのはスマートグラス向けアプリ開発のドキュメントでした。
Even G2 の BLE 仕様は載っていませんでした。

さらに探し回り、MentraOS の次の 2 ファイルに、Even G2 の通信部分がまとまっていることを発見しました!

公式の仕様書が存在しない以上、現時点では、実際に動いているこの 2 ファイルの実装が「実質の仕様」と言えます。
本記事に出てくる仕様は、すべてこの 2 ファイルを読んで確認したものになります。

本記事では、以降この 2 ファイルを「一次資料」と呼びます。

Note

Even G1 向けの OSS が幾つか存在するが、プロトコル層は Even G2 に流用できない。

Even G2 では UUID が全面的に入れ替わり、フレーミングも protobuf と CRC-16 の組み合わせに変わっていた。

左右のアームを 1 台として認識させる

前述のとおり、左右のアームは別々の BLE デバイスとしてアドバタイズされます。
この左右のアームを 1 台のグラスに見立てて、アプリ側で簡単に制御できるようにしました。

スキャンは名前プレフィックスで絞り込みます。

// lib/src/ble/g2_scanner.dart
await UniversalBle.startScan(
  scanFilter: ScanFilter(withNamePrefix: ['Even G2']),
);

デバイス名は Even G2_32_L_XXXXXX のような形式です。
_L__R_ が左右を表し、その手前までが同一グラスを指す識別子になります。

// lib/src/ble/g2_scanner.dart
// _found は pairKey をキーにした Map。G2Glasses は左右 2 台をまとめて持つ入れ物
final pairKey = name.replaceFirst(RegExp(r'_[LR]_.*$'), '');
final glasses = _found.putIfAbsent(pairKey, () => G2Glasses(pairKey));

また、シリアルはアドバタイズの manufacturer data に入っています。
その先頭 2 バイトが "ER" で、後ろに 14 文字のシリアルが続きます。

// lib/src/ble/g2_scanner.dart
// universal_ble は先頭 2 バイトをリトルエンディアンの companyId として解釈する
static const int _erCompanyId = 0x5245; // 'E' | 'R' << 8

String? _serialNumberFrom(BleDevice device) {
  for (final md in device.manufacturerDataList) {
    if (md.companyId == _erCompanyId && md.payload.length >= 14) {
      // 制御文字と非 ASCII を落とす(一次資料の extractSN と同じ扱い)
      return String.fromCharCodes(
          md.payload.sublist(0, 14).where((b) => b >= 0x20 && b <= 0x7E));
    }
  }
  return null;
}

図にすると次のようになります。

左右 2 台のアドバタイズから名前の L と R 以降を落として pairKey を作り、1 つの G2Glasses に束ねる流れの図。シリアル番号での判定は iOS でアドバタイズが出ないため使えないという注記つき

下の画像は、アプリ上のスキャン結果に Even G2 が 1 台として表示され、検出: L / R と表示された瞬間です。

スキャン結果に Even G2 が 1 台として表示され、検出: L / R と出ているアプリのスクリーンショット。シリアル番号はマスクしてある

理想を言えばシリアル番号でペアを判定したかったです。

次項で詳細を語りますが、iOS がシステムレベルでアームの接続を維持し続けてしまいます。
接続中のデバイスはアドバタイズを出さないのでシリアルも取れません。

そのため even_g2_ble ではデバイス名でペアを判定するようにしました。

iOS で右アームだけスキャンに出てこない

iOS でデバイスのスキャンを実行すると、右アームがいつまでも見つかりませんでした。

原因は iOS 側にありました。
左右アームをペアリング済みの iPhone では、公式アプリや開発アプリを終了しても、iOS がシステムレベルで接続を維持し続けます。
BLE では接続中のデバイスはアドバタイズを止めるため、スキャンで見つけようとしても出てきません。

2 つの対処が必要でした。

対処 1: 手動で Bluetooth の登録を解除する

iPhone の設定から Bluetooth を開き、Even G2 の登録を解除します。
「接続解除」だけでは iOS が自動で再接続してしまうため、登録解除まで行う必要がありました。

これは、どうしても人間が手動で行う必要があります。

iOS の Bluetooth 設定で Even G2_32_R_ で始まるデバイスの詳細を開き、デバイスの登録を解除の確認ダイアログが出ているスクリーンショット。名前の末尾はマスクしてある

対処 2: 接続済みデバイスをスキャン結果に混ぜる

コード側の対処で、システムが保持している接続済みデバイスを取得してスキャン結果に混ぜるようにしました。

// lib/src/ble/g2_scanner.dart
// primaryServiceUuid は G2 の write と notify を含むサービス(iOS では指定必須)
if (Platform.isIOS) {
  // 実装では取得失敗を try/catch で握りつぶし、スキャンは継続する(ここでは省略)
  final system = await UniversalBle.getSystemDevices(
    withServices: [primaryServiceUuid],
  );
  
  // スキャンで見つけたときと同じ経路に流し、左右のペアに束ねる
  system.forEach(_onScanResult);
}

Note

Android では getSystemDevices が内部で接続済みデバイスへのサービス探索を行い、10 秒のタイムアウト例外を起こすことがある。
なのでこの処置は、iOS 限定にしている。

サービス UUID を固定すると交信に失敗した

BLE のデバイスは、サービスという入れ物の中に キャラクタリスティック という読み書きの窓口を持ちます。
通信するには、どのサービスのどの窓口かをペアで指定する必要があります。

一次資料には両方の UUID が定数として書かれていました。
そのまま移植すればいいかと思いきや…実機で試すと交信に失敗しました。

しかたなく調査用のコードを書いて、実機(ファームウェア 2.2.1.4)でサービス一覧のログを取りました。
すると、一次資料にある親サービスがそもそも存在しませんでした!

左に一次資料が想定する 1 つのサービスの下に 3 つのキャラクタリスティックがぶら下がった木、右に実機で観測された複数サービスに分かれた木を並べた図

キャラクタリスティックの UUID は一次資料どおりで、親サービスだけが違っていたのです。

Note

一次資料の実装が動いているのは、iOS の CoreBluetooth と Android の GATT API では「全サービスを列挙してキャラクタリスティック UUID の一致で探す」書き方ができるからだった。

(なので、親サービスを指定しなくても目的の窓口にたどり着ける。)

universal_ble はサービスとキャラクタリスティックのペア指定が必須なので、探索結果から親サービスを自力で解決することにしました。

// lib/src/ble/g2_side_connection.dart
static String _parentService(List<BleService> services, String charUuid) {
  for (final service in services) {
    for (final c in service.characteristics) {
      if (c.uuid == charUuid) return service.uuid;
    }
  }
  
  throw StateError('キャラクタリスティック $charUuid が見つからない');
}

接続処理の中で、書き込み用と通知用の親サービスをそれぞれ解決してから購読するようにしています。

// lib/src/ble/g2_side_connection.dart
// writeCharUuid などのキャラクタリスティック UUID は一次資料の定数をそのまま使う
final services = await UniversalBle.discoverServices(deviceId);

_writeServiceUuid = _parentService(services, writeCharUuid);
final protocolServiceUuid = _parentService(services, protocolNotifyCharUuid);
final audioServiceUuid = _parentService(services, audioNotifyCharUuid);

await UniversalBle.subscribeNotifications(
    deviceId, protocolServiceUuid, protocolNotifyCharUuid);
await UniversalBle.subscribeNotifications(
    deviceId, audioServiceUuid, audioNotifyCharUuid);

認証シーケンス

接続しただけではコマンドを受け付けてもらえません。
グラス側のアプリ層に対して認証を通す必要がありました。

手順は次のコードのとおりです。
送信先は 2 通りあります。
認証は左右へ、以降は右だけへ送ります。

// lib/src/client/even_g2_client.dart(_runAuthSequence の一部)
// _sendTo は指定した側へメッセージを書き込む
// (実装には右アーム専用の別名もあるが、ここでは _sendTo に揃えた。
//   実装では各送信をタイムアウト時に再送するラッパーで包んでいる)

// 認証は左右それぞれに送る
await _sendTo(_left, msg.authentication(
    magicRandom: _nextMagicRandom(), phoneType: phoneType));
await Future<void>.delayed(_authStepGap);  // _authStepGap = 200 ms
await _sendTo(_right, msg.authentication(
    magicRandom: _nextMagicRandom(), phoneType: phoneType));
await Future<void>.delayed(_authStepGap);

// 以降は右だけ
await _sendTo(_right, msg.pipeRoleChange(magicRandom: _nextMagicRandom()));
await Future<void>.delayed(_authStepGap);

final now = DateTime.now();
await _sendTo(_right, msg.timeSync(
  magicRandom: _nextMagicRandom(),
  unixSeconds:
      now.millisecondsSinceEpoch ~/ 1000 + now.timeZoneOffset.inSeconds,
));
await Future<void>.delayed(_authStepGap);

await _sendTo(_right, msg.onboardingFinish(magicRandom: _nextMagicRandom()));
await _sendTo(_right, msg.gestureCtrlInit(magicRandom: _nextMagicRandom()));

各ステップの送信先と目的をまとめると次のようになります。

ステップ送信先アーム目的
authentication左右それぞれアプリ層の認証を通す
pipeRoleChange右のみ右アームをコマンドの受け口にする
timeSync右のみグラスの時刻を合わせる
onboardingFinish右のみ初期設定済みとして扱わせる
gestureCtrlInit右のみタッチイベントの通知を開始させる

主要なステップの間に 200 ms の待ちを入れているのは、連続で送るとグラスが取りこぼすことがあったからです。
左右アームそれぞれから secAuth が返ってきたら認証完了になります。

コマンドは右アームにだけ送る

前述のコードで pipeRoleChange 以降が右アームだけにコマンド送信しているのは理由があります。

Even G2 では右アームがコマンドの受け口になり、右が左へ中継するためです。
一次資料の送信関数もデフォルト引数が left: false, right: true になっていました。
左右のアームそれぞれに送るのは、認証と後述するハートビートの 2 つのケースのみです。

Note

最初の実装では全コマンドを左右に送っていた。

そのせいで表示が二重になったり、応答が返ってこなかったりした。

スマートフォンから右アームへコマンドが送られ、右アームが左アームへ中継する経路の図。認証とハートビートだけはスマートフォンから左右の両方へ直接送られる

左右アームへの送信先を整理するとこうなります。

コマンド種別送信先
認証左右それぞれ
ハートビート 2 種左右へ同時
timeSynconboardingFinishgestureCtrlInit右のみ
ページ、テキスト、画像、音声制御右のみ

テキストを表示する

グラスの表示は、ページという単位の上にコンテナを置く構造になっています。
テキストを出すには、まずテキスト用のコンテナを持つページを作る必要があります。

グラスの表示領域全体がページで、その中にテキスト用のコンテナが 1 つ置かれている構造の図

コードは次のようになります。

// lib/src/client/even_g2_client.dart
Future<void> displayText(String text) async {
  if (!_pageCreated) {
    // _textPageContainers はテキスト 1 個ぶんのコンテナ定義を組み立てて返す
    final page = msg.createStartupPage(
      _textPageContainers(text),
      magicRandom: _nextMagicRandom(),
    );
    await _sendTo(_right, page);
    _pageCreated = true;
    return;
  }
  
  // 2 回目以降はページを作り直さず、コンテナの中身だけ差し替える。
  // ACK が返らないことがあるので、一次資料と同様に同じ内容を 2 回送る
  await _sendTo(_right, msg.updateText(
    containerId: _textContainerId,
    content: text,
    magicRandom: _nextMagicRandom(),
  ));
  await _sendTo(_right, msg.updateText(
    containerId: _textContainerId,
    content: text,
    magicRandom: _nextMagicRandom(),
  ));
}

Tip

実装にはもう 1 つ、別のページを表示している最中に呼ばれたときの分岐がある。

前編ではテキストしか出さないので省いた。

ページを毎回作り直さないのには理由があります。
ページを閉じて作り直す操作を繰り返すと、グラス側のページセッションが固まり、以後どのコマンドを送っても描画されなくなるからです。
この問題は後編で詳しく扱います。

日本語も問題なく表示されました。

Even G2 のレンズ越しに、こんにちは、Even G2 x Flutter ! という緑色のテキストが表示されている写真

ハートビートを止めない

接続維持のための送信 (ハートビート) を実装しました。

// lib/src/client/even_g2_client.dart(_startHeartbeat)
// _heartbeatInterval = 5 秒。書き込み失敗は未処理例外にしない
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
  unawaited(_sendToBoth(
      msg.baseConnHeartbeat(magicRandom: _nextMagicRandom())).catchError((_) {}));
  unawaited(_sendToBoth(
      msg.evenHubHeartbeat(magicRandom: _nextMagicRandom())).catchError((_) {}));
});

種類の違うハートビートを 2 つ送るようにしました。

種別宛先の層役割
baseConnHeartbeatグラスの接続管理(デバイス設定系)生存通知。途絶えるとグラス側が接続を落とす
evenHubHeartbeatグラス側のアプリ層アプリが生きていることを知らせる

どちらも 5 秒周期で左右のアームに送信します。
片方のアームへのハートビートが途絶えると、グラス側が接続を維持しなくなり、リンクが切断されます。
(スマホ側には supervision timeout、GATT status=8 として見える)

周期は実機で試して 5 秒に落ち着きました。
Timer.periodic はメインスレッドが詰まると発火が遅れるため、遅れても途絶と見なされないよう間隔は短めに取っています。

起動からテキスト表示までの一連の流れ

起動からテキスト表示までのシーケンス図になります。

コードは次のようになります。

// 開発アプリ側から見た全体の流れ
import 'package:even_g2_ble/even_g2_ble.dart';
import 'package:universal_ble/universal_ble.dart';

Future<void> main() async {
  // 0. BLE 権限(Android は実行時許可が要る)
  if (!await UniversalBle.hasPermissions()) {
    await UniversalBle.requestPermissions();
  }

  // 1. スキャンして左右が揃ったペアを待つ。
  //    購読は start() より先に張る。iOS でシステム接続済みのアームは
  //    start() の中で一度だけ流れ、後から購読すると受け取れない
  final scanner = G2Scanner();
  final glassesFuture = scanner.results
      .expand((pairs) => pairs)
      .firstWhere((g) => g.isComplete);
  await scanner.start();
  final glasses = await glassesFuture;
  await scanner.stop();

  // 2. 接続、認証、ハートビート開始まで
  final client = EvenG2Client(glasses);
  await client.connect();

  // 3. 表示
  await client.displayText('こんにちは');

  // 4. 後片付け
  await client.dispose();
}

connect() の中では、左右アームへの接続から認証までが順に実行されます。

// lib/src/client/even_g2_client.dart
Future<void> connect() async {
  _setState(G2ConnectionState.connecting);

  // 左右それぞれ: BLE 接続 → MTU 確保 → 親サービス解決 → notify 購読
  await Future.wait([_left.connect(), _right.connect()]);

  _setState(G2ConnectionState.authenticating);
  await _runAuthSequence();  // 左右へ authentication、以降は右アームだけ
  await _waitForAuth();      // 左右から secAuth が返るまで待つ

  _startHeartbeat();         // 5 秒周期、左右両方へ 2 種
  _setState(G2ConnectionState.connected);
}

アーム 1 本ごとの接続処理はこうなります。

// lib/src/ble/g2_side_connection.dart
Future<void> connect() async {
  await UniversalBle.connect(deviceId);
  if (Platform.isAndroid) {
    // 最大パケット 246B と ATT ヘッダを収める(Android 14 以降は自動で 517 になる)
    await UniversalBle.requestMtu(deviceId, 512);
    // ... 接続優先度の引き上げ
  }
  final services = await UniversalBle.discoverServices(deviceId);
  _writeServiceUuid = _parentService(services, writeCharUuid);
  // ... プロトコル notify と音声 notify を購読
}

まとめ

公式アプリを介さず、スキャンから認証を通して Even G2 に日本語を表示するところまで出来ました!

今回遭遇した 3 点のつまづきは、一次資料を読むだけでは分かりませんでした。
実機で何度もアプリを動かし、試行錯誤して解決できました。

つまづき原因対処実装箇所
1. iOS で右アームが見つからないシステムが接続を保持しアドバタイズが止まる登録解除と getSystemDevices の併用scanner.start() の中
2. 接続後に必ず失敗する定数の親サービスが実機に存在しない探索結果から親サービスを動的に解決片側の connect() の中
3. コマンドが効かない、表示が二重になる左右両方に送っていた認証とハートビート以外は右アームだけへ送る_runAuthSequence() 以降

後編では、前編でやらなかった新しい試みに進みます。
マイクの音声を受け取り、文字起こし(STT)をしてみます。
また、Even G2 特有の画像とリストにも挑戦します!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる