UI 設計書に従って、BLE-chat ツールに画面を追加

再びコードをもとに戻して、BLE-chat ツールに「設定」と「著作権表示」の画面を追加してみます。
あらかじめ、UI 設計書を用意して、これで一気に追加できるかを試していきます。

Claude Code で UI 設計書通りに追加する

UI 設計にしても、後から画面を追加することはよくあるわけで、そういうときに最初に用意した仕様書だけでなく、後付けができるほうが便利です。これもコードが多くなるとどうなるかわかりませんが。

暫くすると、設定画面等が再現できます。

この場合、UI 設計書通りになるので、何度やっても同じ画面が生成されるはずです。プロンプトの場合は、同じプロンプトを入れても微妙に違う画面になることが多いのですが、UI 設計書があればそれに沿った画面が再現できるというわけです。

同じパターンで、設定や著作権表示の画面以外のログ出力やデバッグ設定などの画面も作成できるようになるでしょう。

GitHub Copilot で UI 設計書通りに追加する

同じことを GitHub Copilot でもやってみます。

agents/UI設計書_設定_著作権.md に従って画面を追加して。

実に Claude Code と同じ画面ができあがります…が、右上の歯車ボタンが「・・・」になっているのが、ちょっと違いますね。画面遷移自体は「設定」→「著作権表示」となっているのですが。

このあたり UI 設計にある

## 2. 画面遷移

```mermaid
flowchart LR
    A[チャット画面] -->|メニューから設定| B[設定画面]
    B -->|戻る| A
    B -->|著作権情報| C[著作権情報画面]
    C -->|戻る| B
    B -->|送信者ID編集| D[送信者ID編集ダイアログ]
    D -->|保存| B
    D -->|キャンセル| B

の部分で「メニューからの設定」の解釈の違いっぽいです。

考察

このように後から UI 設計書を追加して、それに従って既存のコードに画面を追加することは可能です。プロンプトでちまちま追加するりも、設計書.md のようにまとめて AI に渡していたほうが効率的なのは確かです。

が、じゃあ、UI 設計書はどこまで作ればいいのか?という問題があります。今回のように、数行のプロンプトで作った場合、Claude Code と GitHub Copilot では異なる画面ができてしまいます。個人開発ならばそれでもいいのですが、会社のシステムのような場合はこれでは困ります。となると、なんらかの画面設計書を作った上で、その画面フォーマットンに合わせて、どちらの AI を使っても同じ画面できるのが望ましいのです。

さて、ここで作成している「UI設計書_設定_著作権.md」は、Claude Code で作成した画面を元にして、GitHub Copilot が逆作成したものです。

本来ならば、

1. 画面設計書を作成する
2. 画面設計書に従って、Cluade Code あるいは GitHub Copilot で画面を作成する

という2手順になるところですが、

1. 数行のプロンプトで Claude Code で画面コードを作成する
2. 画面コードから、GitHub Copilot で画面設計書を作成する
3. 画面設計書に従って、Cluade Code あるいは GitHub Copilot で画面を作成する

という3手順になっています。

前者のほうは、ウォーターフォール開発のようなもので、設計からコードという一方向の流れがあります。ある意味で、仕様書駆動開発も似たようなものです。流れが一方向になってしまうので、初手である画面設計書をしっかり作り込むことが必要になってきます。

後者のほうは、アジャイル開発…というよりも、スパイラル開発やプロトタイプ開発に近いです。最初にざっくりとした動作する画面を作っておいて、実際に動かしながら再び設計書を見直しあるいは追加していきます。

従来型のスパイラル開発の場合は、スパンを1か月程度にする場合が多く(アジャイルのスプリントとは違って、設計書からコード作成、テストまでを通すので)、このようなやり方は時間が掛かってしまうのですが、AI を使うことでコード生成部分や設計書作成部分を大幅に短縮できます。

1. 最初のスパイラルとして、数行のプロンプトを書く
2. 画面コードを生成する
3. (ここではないが、実際にテストを実行する)
4. 画面の動作を確認する
5. 2回目のスパイラルとして、画面コードから画面設計書を生成する
6. (ここではないが、画面設計書を見直す)
7. 画面設計書に従って、画面コードを生成する
8. (ここではないが、再びテストを実行する)

というイテレーション開発のサイクル廻っていると考えられます。

これを人間の開発者やるならば、2 と 7 の部分で時間が掛かってしまってやりきれないのですが、この部分を AI に任せてしまいます。そうなると、スパイラル開発のサイクルを高速に回す≒スパイラルの期間を短縮することが可能ですよね。

また、画面設計書も、数行のプロンプトを元にしてコード生成した後に逆生成することにすれば、追加機能や追加画面を作るときの設計の作成の手間も省力化できるかもしれません。
これは、3回目のスパイラルをいれて試してみます。

コード

https://github.com/moonmile/BLE-chat

  • branch: dev/add-setting-ui-claude : 設計書から Claude Code で 追加
  • branch: dev/add-setting-ui-copilot : 設計書から GitHub Copilot で 追加


カテゴリー: 開発 | コメントする

AI コード生成した BLE5-chat ツールを整備(設定画面の追加)

Claude Code である程度完成してしまったコードを、少しずつ整備していいきたい(あるいは機能を追加していきたい)としたとき、開発プロジェクトとしては、

  • 細かいテストをどうするのか?
  • 機能追加をしたときの、テストをどうするのか?
  • コードレビューは必要か?

の視点で「開発手法」を考えていく必要がある…と思う訳ですが、いざ、AI 時代のコード修正なるものを考えてみると、何から手を付けてよいものやらという感じになってしまったので、ここは、試行錯誤的に「何か手を付けてみて、何らかの障害にぶち当たるまで試してみる」という形でやってみます。

初期の画面に「設定」と「コピーライト」を追加する

BLE-Chat ツールでは、受信したメッセージと送信する入力欄がある1枚だけ(MainActivity.kt)で簡潔しています。これに「設定」と「コピーライト」を追加してみます。

このあたり、設定用の画面とコピーライト用の画面を設計するところなのですが、このあたりの定番の画面ならば雑に追加しても大丈夫であろうということで、agents/ に .md ファイルを作るのではなく、プロンプトで指定してみます。Android アプリの画面の追加は FolkBears で経験済みなので、そこそこうまく追加してくれるはずです。

Claude Code のプロンプトで追加

Claude Code のプロンプトで雑に指定しいます。

「設定」画面と「コピーライト」画面に追加して。

この場合、定番の方式で追加するので、AI がスタンダードで考える画面の構成で追加してくれます。と言いますか、定番の方法で追加してしまいます。

  • SettingsScreen.kt
  • CopyrightScreen.kt

ファイル名や画面構成は、AI が適当に決めるので、それに従います。逆に言えば、社内の標準フォーマットとかコード規約などを含めることが難しいです。個人開発やツール程度ならばいいけれど、なんらかの標準にあわせる場合には、Agents に .md ファイルで規定を作るか、サンプル用の画面を作るほうがよいでしょうね。

左上に歯車のボタンを付けて、「設定」→「著作権情報」という流れで画面遷移させます。

設定の画面では「送信者ID」や「送信継続時間」などがあるので、これをアプリから固有のもににするのか、ユーザーが設定できるようにするのか、などを開発時に考える必要がでてきます。

送信者IDは変更できるようになっているのですが、実機で試してみます。

SettingsItem(
    label = "送信者ID",
    value = selfId,
    trailingIcon = {
        IconButton(onClick = { showEditDialog = true }) {
            Icon(
                imageVector = Icons.Default.Edit,
                contentDescription = "編集",
                tint = MaterialTheme.colorScheme.primary
            )
        }
    }
)

初期値の「21e4c75c」から変更後の「11112222」に変わっているので大丈夫そうです。

// SharedPreferences から selfId を読み込み、Flow で保持
private val selfIdFlow: MutableStateFlow<String> by lazy {
    val stored = prefs.getString("selfId", null)
        ?: UUID.randomUUID().toString().take(8).also { saveSelfId(it) }
    MutableStateFlow(stored)
}

送信者IDは、MainActivity#selfIdFlow として保持されているので、文字列8文字であれば何でもいいのですが、このあたりは、設計書として書くか、AI コーディングのままとするとかで判断の迷うところです。チャットツールとしては、ニックネームを自分でつけたいところなので、8文字の制限はきついかもしれません。

この他に、BLE の設定値として、送信継続時間や重複排除 TTLなどがありますが、これは SettingsScreen.kt に直が気になっています。

// ── BLE 設定セクション ─────────────────────────────────
SettingsSectionHeader(title = "BLE 設定")

SettingsItem(
    label = "送信継続時間",
    value = "10 秒(固定)"
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

SettingsItem(
    label = "重複排除 TTL",
    value = "30 秒(固定)"
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

SettingsItem(
    label = "参加者タイムアウト",
    value = "60 秒(固定)"
)

このあたりの値は、build.gradle.kts に定数として定義しておいて、SettingsScreen.kt 画面で表示させるほうがよいので、これを変更してみましょう。

送信継続時間、重複排除 TTL、参加者タイムアウト の秒数を、build.gradle.kts に定数として定義できるように変更して。それらの値を使って、SettingsScreen.kt などの関連するコードを修正して。

いわゆるリファクタリングで、初期の AI コーディングでマジックナンバー化してしまったものを、環境変数などの設定値として外出しにします。

送信継続時間などは、BLE の受信待ちなどで使っているので、それも修正してもらいます。

defaultConfig {
    applicationId = "net.moonmile.ble5_chat.claude"
    minSdk = 26
    targetSdk = 36
    versionCode = 1
    versionName = "1.0"

    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

    // BLE タイミング定数(秒単位)
    buildConfigField("int", "ADVERTISE_DURATION_SEC",   "10")
    buildConfigField("int", "DUPLICATE_FILTER_TTL_SEC", "30")
    buildConfigField("int", "PEER_TIMEOUT_SEC",         "60")
}

ADVERTISE_DURATION_SEC などが追加されています。

SettingsScreen.kt では、これらの値を BuildConfig から読み込んで表示するようになっています。

// ── BLE 設定セクション ─────────────────────────────────
SettingsSectionHeader(title = "BLE 設定")

SettingsItem(
    label = "送信継続時間",
    value = "${BuildConfig.ADVERTISE_DURATION_SEC} 秒"
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

SettingsItem(
    label = "重複排除 TTL",
    value = "${BuildConfig.DUPLICATE_FILTER_TTL_SEC} 秒"
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

SettingsItem(
    label = "参加者タイムアウト",
    value = "${BuildConfig.PEER_TIMEOUT_SEC} 秒"
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))

おそらく、こんな風なあまりテストの必要ない画面ならば AI へのプロンプトの指示で十分です。その後に、ビルド&実行して、チャットを使って修正指示を出してもそれほど手間ではないでしょう。
実のところ「送信継続時間」が本当に、その時間で停止しているのかなどの動作チェックが必要なのですが、これはまた別の形でテストをします。

GitHub Copilot のプロンプトで追加

コードを元に戻して、GitHub Copilot のプロンプトで同じことをやってみます。

いちからのコード生成は失敗してしまったので、Claude Code で作成したコードをベースに GitHub Copilot を使って追加する形にします。

「設定」画面と「コピーライト」画面に追加して。

GitHub Copilot の場合は、歯車ではなくて、右上の「︙」のメニューから「設定」と「コピーライト」を選ぶ形になりました。これも定番の方法で、AI が考える画面の構成で追加してくれます。

折角なので、設定画面に

  • 送信継続時間(ブロードキャストしている継続時間)
  • 重複排除 TTL(同じ送信者IDのメッセージを重複とみなす時間)
  • 参加者タイムアウト(受信したメッセージの送信者を参加者とみなす時間)

を追加してもらいましょう。

Copilot のほうは、あらかじめ定義されていた BleAdvertiserManager.ADVERTISE_DURATION_MS などから値を拾って表示するようになっています。

SettingRow(
    title = "送信継続時間",
    value = BleAdvertiserManager.DEFAULT_ADVERTISE_DURATION_MS.toSecondsLabel()
)
HorizontalDivider()
SettingRow(
    title = "重複排除 TTL",
    value = DuplicateFilter.DEFAULT_TTL_MS.toSecondsLabel()
)
HorizontalDivider()
SettingRow(
    title = "参加者タイムアウト",
    value = PeerRegistry.DEFAULT_TIMEOUT_MS.toSecondsLabel()
)
HorizontalDivider()

送信者IDが編集できないので、これを追加します。

送信者IDを編集できるようにして。

最後に定数を build.gradle.kts に移動して、SettingsScreen.kt で表示するように変更します。

– 送信継続時間、重複排除 TTL、参加者タイムアウト の秒数を、build.gradle.kts に定数として定義できるように変更して。それらの値を使って、SettingsScreen.kt などの関連するコードを修正して。

同じパターンで build.gradle.kts で定数を定義できるように変更されます。

defaultConfig {
    applicationId = "net.moonmile.ble5_chat.claude"
    minSdk = 26
    targetSdk = 36
    versionCode = 1
    versionName = "1.0"
    buildConfigField("long", "ADVERTISE_DURATION_MS", "10000L")
    buildConfigField("long", "DUPLICATE_FILTER_TTL_MS", "30000L")
    buildConfigField("long", "PEER_TIMEOUT_MS", "60000L")

    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

比較

Copilot のほうはちょっと手間取ったのですが、Claude Code とほぼ同じ画面が作成できます。
おそらく、それぞれのモデルの中にある典型的な設定画面をピックアップしたと思うです。
ただし、これは AI が好き勝手に作った画面なので、ならからのデザインの統一をするときにはこの方式では困ります。
これを agents/ に UI 設計として保存しておき、狙った形で設定画面と著作権表示の画面を作成できるようにしてみます。

この場合、2つの方式があって、

  • 機能を作ってから後から画面のデザインだけ合わせる
  • 最初の画面のデザインを決めておいて、後から機能を追加していく

ということが考えられます。

設定画面の UI 設計書を作成する

さて、どのように AI に渡す UI 設計書を作るかという問題です。

この場合、既にできあがった「設定」と「著作権表示」の画面から、UI 設計書を作って貰うことにします。

SettingsScreen.kt, CopyrightScreen.kt から、UI 設計書を作成して。

Figma などの UI デザインツールを使って UI 設計書を作る方法もあるのですが、今回のような定番の画面であれば「AI が読み取れるような UI 設計書を AI 自身に作って貰う」方が手っ取り早いでしょう。

agents/UI設計書_設定_著作権.md

# UI設計書: 設定画面 / 著作権情報画面

## 1. 対象画面

- 設定画面
  - 実装: `SettingsScreen`
  - ファイル: `src/app/src/main/java/net/moonmile/ble5_chat/claude/ui/SettingsScreen.kt`
- 著作権情報画面
  - 実装: `CopyrightScreen`
  - ファイル: `src/app/src/main/java/net/moonmile/ble5_chat/claude/ui/CopyrightScreen.kt`

## 2. 画面遷移

```mermaid
flowchart LR
    A[チャット画面] -->|メニューから設定| B[設定画面]
    B -->|戻る| A
    B -->|著作権情報| C[著作権情報画面]
    C -->|戻る| B
    B -->|送信者ID編集| D[送信者ID編集ダイアログ]
    D -->|保存| B
    D -->|キャンセル| B
```

## 3. 設定画面

### 3.1 目的

- 端末設定として送信者IDを表示・編集する
- BLE動作に関する設定値を参照表示する
- アプリ情報への導線を提供する

### 3.2 レイアウト構成

- 画面全体は `Scaffold`
- 上部に `TopAppBar`
- 本文は `Column` + `verticalScroll`
- セクションごとに見出しと項目を配置
- 項目間は `HorizontalDivider` で区切る

次は、この UI 設計書をもとに、AI コーディングで「設定」画面と「著作権表示」画面を作成してみます。

コード

https://github.com/moonmile/BLE-chat

  • branch: dev/add-setting-page : Claude Code で 追加
  • branch: dev/add-setting-page-copilot : GitHub Copilot で 追加


カテゴリー: 開発 | コメントする

BLE5-chat ツールを Claude Code で作る

せっかくなので、同じ「概要設計.md」を使って Claude Code で作ってみることにします。

実は、1回目は失敗して、やり直しています。

  • settings.gradle.kts の includeGroupByRegex でエラー発生
  • app/build.gradle.kts の plugins の alias でエラー発生

どちらもファイル構成がわかっていれば、手で直せばいいような感じなのですが、Android Studio を使えない前提≒あまりコードを知らなくて Android アプリが作れるという前提でやるので、もういちど最初から作り直しています。
作り直しても、15 分ぐらいでできあがります。

逆に言えば、初手のところでうまくいかなかった場合は、AI コーディングをやり直したほうが手っ取り早いです。
夜間バッチ的に動かす場合は、課金の問題もあるので手作業での修正が安価かもしれませんが、この程度のアプリ作成ならば何回かやりなおしたほうがよさそうです。

結果は、作成できました!

GitHub Copilot では途中で詰まってしまいましたが、Claude Code の場合は作成できました。


記事的には [Core Bluetooth] アドバタイジングデータを最大255バイトに拡張するAdvertising Extensions https://zenn.dev/shu223/articles/advertising-extensions の話で、この記事では iOS の拡張アドバタイズなので受信のみとなりますが、Android の場合は送受信が可能です。最大 255 バイト程度のデータがブロードキャストできます(iBeacon は 32 バイトです)。

そんな訳で、コネクション無しの状態で、複数の Android 端末に一気にメッセージを送信できます。コネクションしないので、数十台の端末が集まったとしてもメッセージの送受信に負担が掛からないのですが…まあ、数十台は集まりませんね。実際のところは、4,5台ぐらいでしょう。

基本的な送受信の仕組みは iBeacon の送受信と変わらないので、メッセージデータのフォーマットさえあわせてやれば、こんな感じでチャットツールができる。というところまでは確認できました。

さて、思った通りに動作しているか、をどう確認する?

いわゆる動作確認をした後に、細かい動作のチェックをしていきます。
最終的には公開してアプリとして利用できることを考えると、単に「動いた」だけでは不十分です。

  • 細かいテストをどうするのか?
  • 機能追加をしたときの、テストをどうするのか?
  • コードレビューは必要か?

クラス設計を固定化するために、動作確認のためのテストコードを書くとよいだろう。
というのは引き続き。

コード

https://github.com/moonmile/BLE-chat

カテゴリー: 開発 | コメントする

BLE5-chat ツールを GitHub Copilot で作る…が、途中で断念

「概要設計.md」ができあがったので、GitHub Copilot を使って実装をしていきます。Android Studio を立ち上げて Kotlin のプロジェクトをひな型として作成した後に、AI コーディングを進めるとよいです。

たぶん、Claude Code などのバッチ型はプロジェクト作成まで skills などで用意されているのですが、Web アプリ以外はちょっと難しいか…という感じで、Copilot の場合はよく失敗したので、手作業でプロジェクトだけは作っておきます。

ブランチを切る

AI ペアプロをする場合には、この時点でブランチを作成しておきます。
Claude Code の場合は自動でブランチを作ってくれるのですが、AI ペアプロの場合は、人間のほうで適当なタイミングで作成しておくとよいです。いつも通り、タスクやチケットを単位で作るような感じでよいです。

最初の AI コーディング

  • 概要設計.md に従って src/copilot に実装して。

Claude Code の場合も試したいので GitHub Copilot では src/copilot に実装していきます。

ファイルを見ている限り、なんとか AI コーディングが終わった感じですが、残念ながらそのままでは動きません。

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.BLE5chat">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.BLE5chat">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
  • AndroidManifest.xml に BLE を利用する権限を追加して。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

    <uses-permission
        android:name="android.permission.BLUETOOTH"
        android:maxSdkVersion="30" />
    <uses-permission
        android:name="android.permission.BLUETOOTH_ADMIN"
        android:maxSdkVersion="30" />
    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION"
        android:maxSdkVersion="30" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

BLE の権限の設定はできたとして、Android Studio でビルド&実機で実行してみます。

これを見ると、

  • ステータスバーが重なってしまっている
  • 入力画面に「Bluetooth をオンにしてください」と表示されている

初動までを確認する

  • 実機で BLE を ON にしても「Bluetooth をオンにしてください」がでます。
    調査して。

結果として以下が追加で実装されます。

    // Bluetooth アダプタ状態監視用 BroadcastReceiver
    private val bleStateReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
            val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
            AppLogger.debug("BLE adapter state changed: $state")
            lifecycleScope.launch {
                when (state) {
                    BluetoothAdapter.STATE_ON -> {
                        AppLogger.info("Bluetooth turned ON")
                        _uiState.value = _uiState.value.copy(
                            canSend = true,
                            isScanning = true,
                            errorMessage = null
                        )
                    }
                    BluetoothAdapter.STATE_TURNING_OFF,
                    BluetoothAdapter.STATE_OFF -> {
                        AppLogger.info("Bluetooth turned OFF (state=$state)")
                        _uiEffects.emit(ChatUiEffect.NotifyBleDisabled)
                    }
                }
            }
        }
    }

これで一応入力ができるようになるのですが、実は「概要設計.md」には、BleChatService の責務として書いてあります。

#### BleChatService
- 責務
	- `BleAdvertiserManager` と `BleScannerManager` を統合
	- BLE 権限・Adapter 状態を監視
	- アプリの開始/停止に追従して送受信を制御
	- `BroadcastReceiver` で `ACTION_STATE_CHANGED` を受信し、`STATE_TURNING_OFF` / `STATE_OFF` を検知したら送受信を即時中断して `ErrorHandler` へ委譲する
	- BLE OFF 検知時は `ChatUiState.canSend = false` と `ChatUiEffect.ShowToast` を上位へ通知する
- 主なメソッド
	- `start()`
	- `stop()`
	- `send(chatMessage: ChatMessage)`
	- `incomingMessages(): Flow<ChatMessage>`
	- `onBleAdapterStateChanged(state: Int)` ※ BroadcastReceiver から呼ばれる内部ハンドラ

元のコード内にも BleChatService.start(), BleChatService.stop() として使われているので、これ活かす形に書き変えて貰います。

    private fun handleStartChat() {
        lifecycleScope.launch {
            try {
                AppLogger.info("Starting chat")
                _uiState.value = _uiState.value.copy(canSend = true, isScanning = true)
                // TODO: BleChatService.start()
            } catch (e: Exception) {
                AppLogger.error("Failed to start chat", e)
                _uiEffects.emit(ChatUiEffect.ShowToast("チャット開始に失敗しました"))
            }
        }
    }

    private fun handleStopChat() {
        lifecycleScope.launch {
            try {
                AppLogger.info("Stopping chat")
                _uiState.value = _uiState.value.copy(canSend = false, isScanning = false)
                // TODO: BleChatService.stop()
            } catch (e: Exception) {
                AppLogger.error("Failed to stop chat", e)
                _uiEffects.emit(ChatUiEffect.ShowToast("チャット停止に失敗しました"))
            }
        }
    }

実装を設計にあわせる

  • BroadcastReceiver による動的監視の部分を、概要設計.md 通りに、BleChatService を実装して利用できますか?

これで BleChatServiceImpl.kt が実装されます。このあたり、GitHub Copilot が設計を忘れいるのか、それとも Claude Code ならば大丈夫なのか、という問題がありますが、少なくとも「必ずしも設計通りにコーディングをしないこともある」というのが重要です。
今後はどうなるのかわかりませんが、なんらかの形で「設計書通りにコーディングする」というスタイルを促すようなプロンプトの工夫が必要になると思われます。

あるいは、設計書通りにコーディングされているかをチェックするテストコードを追加することになります。

受信状態を実機で確認する

受信状態を実機で確認してみますが、受信人数が 0 人のままになります。かつ、メッセージを送信しても受信ができていません。

  • 複数の端末を起動しても 0 人のままになります。

この手の実機動作確認が AI コーディングの一番の難所になります。

AI としては直しているつもりなのですが、まだ動かないようなので、動作ログを入れて貰います。

  • peerCount が常に 0 になっています。送受信のところにログを入れて、動作確認できるようにして。

ログをチャットに貼り付けて、分析してもらいます。

原因として ScanFilter.setServiceUuid() がうまく働いていない可能性があるとのことなので、ScanFilter を AI が外してみます。

ログは出るようになったのですが、まだ受信できません。

いったん保留

そもそも、手元の Android 端末が BLE5 Extended Advertising を通しているか不明なので、ちょっと方式変えて、疎通試験用のライブラリを作っていくことにします。

カテゴリー: 開発 | コメントする

BLE5 の Extended Advertising を使って AI コーディングを試していく

ちょっと、執筆と新人研修が重なりあってしまったので、ブログの更新が滞ってしまっているのですが、ぼちぼち再開します。
コーディング的には ExcelUnoLike の続きをしたいところですが、どうもあれこれと設計を見直したくなって重たくなりそうなので、先に BLE5 を使ったツールのほうを試していきます。

プログラマから見た AI コーディングの懸念

普段使いのコーディングでは VSCode + GitHub Copilot を使って「AI ペアプロ」という形で進めていきます。AI ペアプロなので、常にプログラマが並走しています。AI に任せっきりというわけでもなく、AI が出力してきたコードを鵜呑みするわけでもなく、ある程度ですが AI コードを常にレビューしながら進めていきます。まあ、いろいろ面倒になって、ちょっとだけ動作確認した後に Git にコミットしてしまうことも多いのですが、場合によっては 1 日分の AI コードを戻してしまうことも多いです。

BLE 絡みと Web API 絡みでプロトタイプ的なコードを結構書いてきたのですが、それ以上の品質に AI コーディングが辿り着くのか?というと、現状では何とも言えません。このあたりは、私が「AI ペアプロ」に留まっている理由でもあり、巷のテクニカルな記事をみると大規模で手間のかかる AI コーディングならば良さそうなのですが、こう一歩ずつ試してくようなプロトタイプ型のコーディングではやっぱり「AI ペアプロ」のほうがよさそうな気がします。

とはいえ、手元で「Claude Code」を入れて(実際には Claude Cowork 用に入れたのですが)試してみると、ああ、確かに Web アプリケーションならば、仕様書を与えてコーディングさせることは可能っぽいです。というか、逆に言えば「AI ペアプロ」的な使い方は Claude Code だとやり辛いです。逆に言えば、夜間に一括でできるようなバッチ的な処理には非常に向いています。おそらく、単体テストの自動化と再帰的なコード修正がやり辛いのは、ここのバッチ的な処理が原因かもしれません。

という訳で、ここまでが愚痴…

AI ペアプロ型とバッチ型の両方を試してみる

恐らく一長一短があると思うのですが、

  • VSCode + GitHub Copilot を使った「AI ペアプロ」型
  • Claude Code を使った「バッチ型」

の両方を試してみます。巷のブログ記事だと、「Web サイトが一瞬でできました」タイプが多いので、仕様が決まったら一気に作る Claude Code のほうが向いているような気もしますが、そのあたりの非 Web アプリの場合はどうなのか?というお試しです。

本来ならば要件定義からスタートしたほうがいいのですが、今回は機能要件レベルからスタートしてみます。機能要件をまとめるときの諸々は Copilot などの Chat ツールを使っています。

## 機能要件

- BLE5 の Extended Advertising を利用して、Android 端末同士でチャットができる
- チャットの内容は、テキストメッセージとする
- iBeacon のようにブロードキャストでメッセージを送信し、受信側はそれを受け取って表示する
- BLE5 通信の届く範囲内であれば、複数の端末が同時にチャットに参加できる

チャットツールなんて「チャットツールを作って」と AI に頼めばさっくりと作ってくれる典型的なパターンなのですが、今回は WEB サーバーを使わないし、Firebase も使わないパターンです。しかも、Android 端末同士限定で、BLE5 の Extended Advertising を使います。Extended Advertising 形式だと、iBeacon のようにブロードキャスト通信ができます。実運用的にはコネクション型を使ったほうがいいのですが、このあたりは趣味的に BLE5 を使ってみることにします。

このあたり、AI にとって難関なのは「物理的な Android を動かさないといけない」というパターンです。これ、バッチ型のほうに非常に不利なのですが、なんとかクリアできるように考えていきたいです。実は AI ペアプロ型の場合も問題が多くて、テストをするときに「AI が提案するコードを人間が動かし続ける」という使役的なパターンが何度となく続きます。確かに、ペアプロなのですから、コード役とドライビング役が交代してもいいのですが、なかなかこれは辛いです。FolkBears の改修しているときに何度となく発生しています。同じパターンは、Web API を作成したときの React での UI のプロトタイプでも発生します。プロトタイプ的に作る場合にはどこかで諦めればいいのですが、製品として品質を上げる場合にはどこまでやるのかが不明になってしまいます。
ここを自分的には何とかしたいところですね。

このあたり、

  • テスト工程を含めて、これも自動化できるように考える
  • 例えば、ログ取得など、単体テスト、結合テストを AI 任せにできるだろうか?

という課題も含めていきます。
クリアすべきテスト項目も AI ペアプロ的にあるいはバッチ的に追加します。

設計する

ここ 1 年ほど AI コーディング(バイブコーディングも含めて)を試してきた結果、経験上、仕事で使う場合には軽く「設計」をしておくと方向性が定まってよい、と結論に至っています。仕様書駆動(spec駆動)ほど詳細に書くわけでもなく、先の「機能要件」ほど大雑把なままではなく、という中間的な位置です。
結局のところ、ウォーターフォール開発にせよアジャイル開発にせよ、途中での変更や見直しからは逃れられないので、そのあたりの余地&人的な手間を考慮する必要がでてきます。
特に、単独でのAI コーディングの場合には、伝達コスト(コミュニケーションコスト)を抑えることができるので、派手な設計書は必要ありません。まあ、多人数での開発の場合には、レビューなどが必要となるのである程度の文書化が必要になるのですが…今回は Android アプリなので小規模なものを対象とします。

実は、機能要件を渡せば VSCode + GitHub Copilot の組み合わせでも結構なところまでできてしまいます。UI なり、メッセージ形式なり、通信フローなりを、既存のものからうまく引っ張ってくるので、ある程度動くものが素早くできあがります。なので、「プロトタイプ的に」作るのは、機能要件だけでも結構 ok です。
ただし、最近の技術ブログや SNS でも話題になっているように、これをどうやってテストしていくのか、将来機能追加があるときにどうやって品質を保証していくのか、という問題がでてきます。このあたりは、開発するアプリ/製品の寿命や運用形態にもよるので、その都度考える必要がでてくるのですが、少なくとも AI コーディングで機能要件だけ渡して、一気に作って貰って放置するのはダメです。
なんらかの設計方式を与えてしまったほうがコードのスタイルが揃い、後での修正や機能追加がやりやすくなります。

クラス設計

  • 機能要件を満たすように、クラス設計を書いて。

とプロンプトを打って、クラス設計を出力させます。

## クラス設計

BLE Extended Advertising を使った「ブロードキャスト型チャット」を成立させるため、責務を以下のように分離する。

### 1. クラス一覧

| クラス名 | 種別 | 主な責務 |
|---|---|---|
| ChatActivity | UI | メッセージ一覧表示、送信入力、画面イベント処理 |
| ChatViewModel | Presentation | UI 状態管理、送信要求、受信イベント購読 |
| ChatRepository | Domain/Application | 送受信ユースケース統合、履歴の仲介 |
| BleChatService | BLE Facade | BLE 初期化、Advertiser/Scanner の開始停止管理 |
| BleAdvertiserManager | BLE Tx | Extended Advertising による送信データの分割・送出 |
| BleScannerManager | BLE Rx | 広告パケット受信、再構成、重複排除 |
| PacketAssembler | Protocol | 受信パケットの順序復元、メッセージ復元 |
| PacketFragmenter | Protocol | テキストメッセージの分割、ヘッダ付与 |
| MessageCodec | Protocol | 文字列とバイト列の変換、バージョン管理 |
| CryptoManager | Security | 暗号化/復号(任意有効化)、鍵管理窓口 |
| DuplicateFilter | Reliability | MessageId による重複受信抑止 |
| PeerRegistry | Session | 参加端末の最終受信時刻管理(参加者把握) |
| ChatMessage | Model | チャット本文、送信者 ID、時刻、MessageId |
| AdvPacket | Model | 1広告単位データ(MessageId、Seq、Total、Payload) |
| AppLogger | Cross-cutting | ログ収集(テスト・障害解析用) |
| ErrorHandler | Cross-cutting | BLE 例外、権限エラー、復旧リトライ判断 |


### 2. 主要クラス詳細

#### ChatActivity
- 責務
- メッセージ一覧表示
- 送信ボタン押下イベントを ViewModel に委譲
- BLE/権限状態の表示
- 主なメソッド
- `onCreate()`
- `observeUiState()`
- `onSendClicked(text: String)`

このクラス設計は最初のたたき台になるものなので、中身を確認していきます。この中身は、適当なクラス設計のテンプレート文書があればそれにフォーマットを合わせてくれます。プランモードで慎重にしてもいいのですが、AI ペアプロ型の場合は、いったん設計の部分で止めておくほうが無難です。まあ、Android & Kotlin のプロジェクトになじみがないとか、BLE 通信のところに知見がないと、設計段階で戸惑ってしまうのですが、今回はある程度 Android アプリを作ったことがあるという前提になります。

今回は、初期型として作りたいので、チャットの返答やクラス設計のサマリを見ていくつかの機能を削っておきます。

  • 暗号化 CryptoManager の機能は削除して。
  • Kotlin の Jetpack Compose を使う場合、ChatViewModel は必要?
  • Compose を利用する前提にして、テストしやすい方式に変更して。
  • 回転 ChatRoute は考えずに、縦置き(ポートレート)の画面構成にして。
  • メッセージは 100 文字以内とするため、PacketAssembler と PacketFragmenter を削除して。
  • ブロードキャストするメッセージは10秒だけ送信する。

大まかなクラス設計ができたら、シーケンス図を使って確認します。

シーケンス図

1年前の FolkBears の改修のときにはフローチャートを手作業で書くしかなかったのですが、いまだと Mermaid 形式で書いてくれます。

  • 主な送受信のフローを、シーケンス図に追加して。
## シーケンス図

### 1. メッセージ送信

mermaid
sequenceDiagram
actor User as User
participant Screen as ChatScreen
participant Activity as MainActivity
participant Repo as ChatRepository
participant Service as BleChatService
participant Codec as MessageCodec
participant Advertiser as BleAdvertiserManager

User->>Screen: メッセージ入力 / 送信押下
Screen->>Activity: onSend(text)
Activity->>Repo: publishMessage(ChatMessage)
Repo->>Service: send(chatMessage)
Service->>Codec: encode(chatMessage.text)
Codec-->>Service: payload
Service->>Advertiser: broadcast(AdvPacket)
Advertiser->>Advertiser: startAdvertising()
Advertiser-->>User: Extended Advertising で送信開始
Advertiser->>Advertiser: 10秒タイマー開始
Advertiser->>Advertiser: stopAdvertising()
Advertiser-->>User: 送信停止
  • メッセージ送信時に BLE OFF にした時の動作を追加。
  • メッセージ受信時に BLE OFF を追加。
  • いくつかのエラーを処理を追加しておきます。

この時点で、「agents\概要設計.md」ファイルは、300 行を超え始めているので、きちんとチェックするにはちょっと手間な量になってきています。シーケンス図やクラス図は、設計の正しさをチェックするためなので、AI コーディングの場合はチェックした後は削除してもよいかもしれません。人間だけでチェックする場合は、設計書として残さないとコーディングのときに困ってしまうのですが、AI コーディングの場合はあまり関係ありません…というか、シーケンス図通りに AI がコーディングするとは限らないという意味ですね。

FolkBears の改修のときは AI にコードからシーケンス図を作って貰ったので、コードを吐き出してからシーケンスをチェックしてもよいかもしれません。

軽く UI 設計をしておく

実際のところは Figma とか使って UI デザインをしたほうがいいのですが、今回はプロトタイプ的に作るので、テキストベースで UI 設計をしてもらいます。コードとしては Jetpack Compose で書いてもらうことにします。

## UI 設計

### 1. 画面レイアウト(ポートレート固定)

画面を上から 3 つの固定領域で構成する。ヘッダと入力領域は固定高さ、メッセージ領域が残りの高さをすべて占有する。

┌─────────────────────────────────────┐
│ ヘッダ(固定) │ ← StatusBar 直下
│ [● BLE 状態] [👥 参加者数] [⟳ スキャン中] │
├─────────────────────────────────────┤
│ │
│ メッセージ一覧(可変・スクロール可) │ ← weight(1f) で拡張
│ │
│ ┌───────────────────────────────┐ │
│ │ HH:MM senderId │ │ ← 受信メッセージ(左寄せ)
│ │ メッセージ本文 │ │
│ └───────────────────────────────┘ │
│ │
│ ┌─────────────────────┐│
│ │ senderId HH:MM ││ ← 自分のメッセージ(右寄せ)
│ │ メッセージ本文 ││
│ └─────────────────────┘│
│ │
├─────────────────────────────────────┤
│ 入力領域(固定) │
│ [TextField(テキスト入力)] [送信ボタン] │
└─────────────────────────────────────┘

これもどこまでコードに反映されるか分からないのですが…正確にコードに反映されないのであれば、UI 設計としてはどこまで細かく指定できるのか?ということになりますね。

UI 設計は Claude Code に読み込ませる必要もあるため、Copilot で作ったものをそのまま貼り付けておきます。

ここで、おおまかな概要設計ができたので、これを GitHub Copilot に読み込ませてコーディングをしていきます。

カテゴリー: 開発 | コメントする

Google NotebookLM 即効活用大全 発売中

https://www.amazon.co.jp/dp/B0GGW3ZC1P

Google NotebookLM で手元の資料を活用しよう、という本です。

https://notebooklm.google.com は Google アカウントがあれば無料で使えるので、本書のサンプル文書を使ってお試しができます。一般的な解説書とは違って、サンプルの文書(PDFや画像ファイルなど)をダウンロードをして手元の NotebookLM で手を動かしながら試せます、という形式になっています。いわゆる、プログラムコードを Git からダウンロードして動作確認ができるタイプの本と思ってよいです。

基本機能は、前半に導入編、基本編と一通り説明があります。日々追加される NotebookLM の各機能について執筆時点での網羅をしています。

ただ、それだけど、どんどん進化してしまう NotebookLM 自体に書籍の内容が追いつきませんよね。それだけだと巷のブログや Note や動画などの解説で十分です。

この本で作りたかったのは、後半にある「ケーススタディ編」です。

NotebookLM に突っ込むサンプル文書を用意して、自分でプロンプトを呼び出して試すことができる、という形式にしてあります。サンプル文書も単にファイルや画像を検索するだけではなくて、実際ありそうな検索や文書をいれてあります。

これを会話形式で、シチュエーションを想定します。

その後に、フローチャート方式で、NotebookLM がどこで活用されるのか確認します。

なんとなく理解できたら、実際に NotebookLM にサンプル文書をいれて動かしてみましょう。というスタイルになっています。

まあ、実際のところは手を動かさなくてもケーススタディを読むだけでも雰囲気が伝わるようになっていますが、NotebookLM の基本技を組み合わせると、仕事やグループワークをするときにこんな風に使えますよ、というケースを34パターン用意してあります。

想定する職業はばらばらなので、自分の職種とは違うものが多いとは思うのですが、使い方のヒントにはなると思います。特に、手元に PDF やテキストデータ、Word ファイルなどで情報が文書として残っている場合は、お得に NotebookLM を活用できます。
ひとまず、NotebookLM で必要そうなファイルを突っ込んでみて、何か質問してみると、きっちりと PDF ファイルの内容を理解して回答してくれることがわかります。抜き出しだけではなくて、箇条書きでまとめたり表形式にフォーマットしなおしたりすることも可能です。

このあたり、以前 NotebookLM を使ってはみたけれど、いまいち使い方がわからなくてやめてしまった、という人に一番おすすめです。あと、NotebookLM ってのは聞いてはいるけど、いまいち何ができるのか分からない、という人にも二番目におすすめします。

カテゴリー: 開発 | コメントする

LibreOffice のサイドバーの作成が俺の理解を越えているので AI に作って貰う…ことにする

M365 の Copilot が絶賛不評中らしいのですが、まあ、MS-Office で Copilot を使う理由としたら Teams とかパワポ関係だと思うので、Word とか Excel ではいまひとつなんですよ。なのにあの高い値段をペイできるのだろうか? と個人的には思うのですが、企業としてはどうなんでしょう?

それはておき、LibreOffice でも Copilot のようにサイドバーを置いて AI にいろいろやって貰うといいんじゃないか?と思いつつも、外部から操作しかできないのかな、と思っていたところですが、実はできます。

mihailthebuilder/librethinker-extension: AI Copilot for LibreOffice Writer https://github.com/mihailthebuilder/librethinker-extension

NikolaiRadke/LibreAssist: Agentic Working with LibreOffice https://github.com/NikolaiRadke/LibreAssist

拡張機能は https://extensions.libreoffice.org/ でダウンロードができます。タイミングがよくて、LibreAssist なんて 1週間前に出来立てのほやほやです。まさしく、ホクホクですね。

ExcelLikeUno を作るのも AI からアクセスしやいう作りにすればいいのじゃないかという別な思惑があって、Copilot の Excel 版が難航していたのを見ていて、どうも MCP あたりでプロンプトの指示をうまくマクロに変換できればうまくできそう、っぽいことは考えていたのですが…まあ、考えていただけです。

ただ、あの Copilot のサイドバーっぽいものを LibreOffice に作るとなると「拡張機能」を作らねばならず、これがなかなか大変そうだったのです。

で、結論から言うと、大変です。サイドバーにテキストとボタンを配置するだけで一苦労でした。

実は先の librethinker-extension と LibreAssist は、unodit https://github.com/kelsa-pi/unodit というのをベースにしてサイドバーを表示しています。unodit は、もともと LibreOffice の Basic のダイアログを Python にコンバートするツールです。どうやら、LibreOffice のダイアログが GTK がベースになっているのですが、GTK の glade が吐き出す .ui の書き方とは異なるようなのです。よくわからないのですが、GTK がベースらしいのに、Python マクロ(UNO API?)から .ui が読み込めないのはどうかと思うのですが、従来型の .xdl は読み込めます。

glade の .ui 形式

<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.40.0 -->
<interface>
  <requires lib="gtk+" version="3.24"/>
  <object class="GtkWindow">
    <property name="can-focus">False</property>
    <child>
      <object class="GtkBox" id="root">
        <property name="visible">True</property>
        <property name="can-focus">False</property>
        <property name="orientation">vertical</property>
        <child>
          <object class="GtkScrolledWindow">
            <property name="visible">True</property>
            <property name="can-focus">True</property>
            <property name="shadow-type">in</property>
            <child>
              <object class="GtkTextView" id="chat_log">
                <property name="visible">True</property>
                <property name="can-focus">True</property>
              </object>
            </child>
          </object>
          <packing>
            <property name="expand">False</property>
            <property name="fill">True</property>
            <property name="position">0</property>
          </packing>
        </child>

LibreOffice の .xdl 形式

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dlg:window PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "dialog.dtd">
<dlg:window xmlns:dlg="http://openoffice.org/2000/dialog" xmlns:script="http://openoffice.org/2000/script"
 dlg:id="CopilotPanel" dlg:left="10" dlg:top="10" dlg:width="260" dlg:height="220"
 dlg:closeable="false" dlg:moveable="false" dlg:withtitlebar="false">
  <dlg:styles>
    <dlg:style dlg:style-id="readonly" dlg:multi-line="true" dlg:read-only="true" dlg:tab-stop="false" dlg:border="true"/>
  </dlg:styles>
  <dlg:control dlg:id="log" dlg:style-id="readonly" dlg:type="multi-line-edit" dlg:left="8" dlg:top="8" dlg:width="244" dlg:height="130"/>
  <dlg:control dlg:id="input" dlg:type="edit" dlg:left="8" dlg:top="150" dlg:width="244" dlg:height="12"/>
  <dlg:control dlg:id="send" dlg:type="pushbutton" dlg:left="8" dlg:top="170" dlg:width="60" dlg:height="14" dlg:value="Submit"/>
</dlg:window>

どっちがどうという訳でもないのですが、

  • .xdl 形式は LibreOffice のダイアログ編集でしか作れない
  • .ui 形式は Windows では MSYS2 の glade をインストールする必要がある

という難点があって、さらに言えば、UNO API では .ui 形式のロードがサポートされていません。これもなんだかよく分からないのですが、仕方がありません。

で、せっかく python を使っているのに Basic に引きずられるのも嫌なので、どうせならば SwiftUI や Jetpack Compose みたいな宣言的 UI を作れないかと思って模索している途中です。と言いますか、ひな形のサイドバーができるところまでを難航中です。

できることならば、このループに入りたいです。

## 目的

- LibreOffice の Sidebar を開発する環境を整える
- Sidebar コードのひな形
- Sidebar UI を SwiftUI 風の DSL で記述できる
- スタンドアローン環境で Sidebar UI をプレビューできる
- Sidebar UI を LibreOffice に組み込む

## 進め方

1. Sidebar の UI を SwiftUI 風にコーディング
2. スタンドアローンで動作確認(イベント処理などは無視) レイアウトの確認
3. Sidebar のコードに入れて動作確認
4. 再び、スタンドアローンでチェックが可能

ちなみにサウドバーを作るときには、以下の構造を理解しないといけません。

## フォルダ構成

├── sidebar/ # LibreOffice 拡張(.oxt)関連
│ ├── description.xml
│ ├── Addons.xcu
│ ├── Sidebar.xcu
│ ├── Factory.xcu
│ ├── pythonpath/
│ │ ├── __init__.py
│ │ ├── sidebar.py # Sidebar UI コントローラ
│ │ └── uno_entry.py # UNO エントリーポイント
│ │ └── ui_definitions/
│ │ └── chat_ui.py ← ビルド時に自動コピーされる
│ └── META-INF/
│ └── manifest.xml

なんか、なつかしの Java プロジェクトの構成ですね。この構成は暫くやっていない(20年ほどやっていない)ので、かなり新鮮です…が、いまさら、あれこと悩みたくはありません。

Sidebar.xcu とか、Factory.xcu とか、中身の繋がりがややこしい(間違えると動かない)ので、次回は忘備録的に残しておきます。できれば、ここはツール化して自動化しておきたい。

カテゴリー: 開発, LibreOffice | LibreOffice のサイドバーの作成が俺の理解を越えているので AI に作って貰う…ことにする はコメントを受け付けていません

複数の iBeacon が配置されたとき発見遅延は増加するのか?

ペリフェラル(iBeacon発信機)とセントラル(iBeacon受信機)が 1対1 のときでは、受信確率をポアソン近似で計算ができます。

iBeacon の受信遅延の確率を計算する | Moonmile Solutions Blog

  • 発信間隔 Advertising Interval
  • 受信ウィンドウ Scan Window
  • 受信間隔 Scan Interval

図 受信遅延の確率

仮説

複数の iBeacon が配置されたとき、受信遅延は増加するのではないか? という仮説を立てていました。iBeacon の発信チャンネルは 37, 38, 39 の 3 チャンネルになっているので、3台以上の iBeacon 発信機が配置されたときにチャンネルの競合が発生するのではないか、と考えたのです。

実際のところ

iBeacon の発信時間は非常に短く 0.3 ms 程度です。ある程度の Scan Window(この場合は、100 ms など)を確保しておけば、iBeacon の電文が重なることはまずありません。あったとしても非常に少ないでしょう。

また、iBeacon 受信(セントラル)側では、ひとつの Scan Window の間に複数の iBeacon 電文をキャッチすることができます。同じ Scan Window に複数の iBeacon が入っていたときには、複数の Scan イベントが発生することになります。

結論

というわけで、複数の iBeacon が配置されたときに受信遅延が増加することはない、という結論になりました。iBeacon の発信時間が非常に短いため、チャンネルの競合が発生することはほとんどありません。また、セントラル側では複数の iBeacon 電文をキャッチすることができるため、受信遅延がさらに増加することもありません。

逆に言えば、受信側の duty 比(Scan Window / Scan Interval)と、発信側の Advertising Interval によって、受信遅延が計算できます。

これ、iBeacon のようにコネクションレスの場合は遅延の問題は少ないのですが、GATT 接続が発生するコネクション版の場合には、多対多の接続が発生すると遅延が多くなります。これは別途。

カテゴリー: 開発, FolkBears | 複数の iBeacon が配置されたとき発見遅延は増加するのか? はコメントを受け付けていません

iBeacon の受信遅延の確率を計算する

iBeacon の受発信が Android/iOS で出来上がったので、具体的に iBeacon の受信確率を実測していきます。

BLE 物理層の詳細とスキャン頻度 | Moonmile Solutions Blog https://www.moonmile.net/blog/archives/11968

問題を簡単に解決するには、

  • 発信機の発信頻度を高くする
  • 受信機のスキャン頻度を高くする

という形にすれば問題はないのですが、バッテリーの問題や、省電力のために、できるだけ発信頻度やスキャン頻度は低く抑えておきたいところです。iOS の場合は、これらの頻度を変えることができないので調節が不可能なのですが、Android の場合は、ADVERTISE_MODE_LOW_POWER を設定することで発信/受信タイミングを調節することができます。

が、この発信頻度と受信頻度の両方とも ADVERTISE_MODE_LOW_POWER にしてしまうとなかなか iBeacon を受信しないという現象が起きます。これは GATT サービスのデバイスの発見のときにも発生します。

受信確率を計算する

変数としては以下の3つを使います。

  • 発信間隔 Advertising Interval
  • 受信ウィンドウ Scan Window
  • 受信間隔 Scan Interval

例えば、以下のように設定をします。

Scan Window: 100 ms
Scan Interval: 1000 ms
Advertising Interval: 100 ms ~ 200 ms

図 受発信の関係

発信する BLE 電文は、Advertising Interval の間隔で発信されます。受信機は Scan Window の間だけスキャンをして、Scan Interval の間隔でスキャンを繰り返します。うまく、Scan Window に当たれば iBeacon が受信できるという訳です。

完全に同期が取れれば、受信確率を高めることができるのですが、実際はそうはいきません。BLE デバイスはそれぞれ独立したタイミングで動いているので、ある程度の幅をもって受信する必要があります。また、発信するタイミングも Advertising Interval を固定してしまうと全て外してしまう可能性がでてくるので、ある程度の幅をもってランダムに Advertising Interval を変えていきます。

iBeacon は発信機で出力した電文を全て受信する必要はありません。どれか1つでも受信できればいいのです。できれば、早めに受け取りたいところです。ここで、受信確率を計算します。

p = Scan Window / Scan Interval: 1秒間に iBeacon を受信する確率
n = 1000 / Advertising Interval: 1秒間に iBeacon が発信される回数

受信側は 1秒間で 0.1 の確率で受信することができ、発信側は1秒間に 6.67 回発信されている訳。受信できない確率が3つめの式になって、(1-p)^λ で計算できるので、0.513 という確率になります。これを1から引けば、受信できる確率 0.487 になります。

これだと、2秒間に1回は受信できそうなので、問題なさそうですね。
これを受信確率 p は変えずに、発信間隔 Advertising Interval だけを変えます。

Scan Window: 100 ms
Scan Interval: 1000 ms
Advertising Interval: 500 ms ~ 2000 ms

発信間隔を少し間延びさせて平均 1250 ms にします。

同じ計算をすると、1秒間に受信できる確率が 0.081 と激減します。この確率だと到底、1秒間に受信できる確率は無理ですね。

ポアソン近似を使う

ポアソン近似を使うと、受信できる確率は以下の式で計算できます。


99% の確率で受信できる秒数を計算

t 秒間で受信できる確率が 99% になるような t を計算します。

99%になる t を求める。

というわけで、約58秒ほど経たないと 99% の確率で受信ができません。つまり、最悪 1 分近く iBeacon の受信が遅延することになります。

つまり、省電力にしようとして発信側で Advertising Interval: 500 ms ~ 2000 ms 程度の間延びした iBeacon を発信してしまうと、最悪 1 分近く受信が遅れる可能性があるということです。

50% 確率の場合は、8.7 秒程度なのでだいたい半分は10秒ぐらい遅れるということになります。このあたりを実測したいところです。実際、Android を使って両方とも ADVERTISE_MODE_LOW_POWER にしてみると、iBeacon の受信がかなり遅れます。

この部分、接触確認アプリ FolkBears を作成したときに、接触時刻がこれだけずれる(確定範囲がある)ということになります。

ADVERTISE_MODE_LOW_POWER の問題

このため、発信と受信の間隔を ADVERTISE_MODE_LOW_POWER にすると、iBeacon やデバイス発見の遅延が 10 秒から 60 秒ぐらいまで遅延するので、接触確認アプリでこれを使ったときには 10 秒から 1分程度のすれ違いを検出できない可能性が高いです。

これは COCOA/EN API の発信/受信間隔にも適用される筈で、iOS の場合は結構頻繁に受発信をしているのですが、Android の場合は省電力設定にされていると、接触を検知しにくい状態であった、という仮説が立てられます。

これを m5stack などを使って実測してみたいところです。

カテゴリー: 開発, FolkBears | iBeacon の受信遅延の確率を計算する はコメントを受け付けていません

iPhone(iOS) で iBeacon と EN API 発信機を作る

接触確認アプリでは Android/iOS の相互で BLE 通信を行うために、iOS での発信機のチェックもしておきます。
iOS の場合は、16 bit Service UUID 形式と Manufacturer Data 形式では発信できません。が、”発信できないこと” を確認するために、あえて両方の実装もしてあります。実際に iOS/Android の受信機で受信ができないことを確認してください。

iOS の場合、BLE の発信タイミングを制御することはできません。受信機の様子を見ると、かなりの頻度で受信するので、Android の Low Latency と同等の発信タイミングで発信しているようです。これは、別途 M5Stack で受信機を作って計測していきたいと思います。

SwiftUI で作る

これも SwiftUI で作ります。最終的には Flutter とか React Native でもよい気がするのですが、BLE まわりの制御が、Android と iOS でかなり異なるのでネイティブのままで作ってあります。

struct ContentView: View {
    var body: some View {
        TabView {
            BeaconTabView()
                .tabItem {
                    Label("Beacon", systemImage: "antenna.radiowaves.left.and.right")
                }

            FolkBearsTabView()
                .tabItem {
                    Label("FolkBears", systemImage: "bear")
                }

            ENTabView()
                .tabItem {
                    Label("EN API", systemImage: "wave.3.right")
                }

            MfDTabView()
                .tabItem {
                    Label("MfD", systemImage: "shippingbox")
                }
        }
    }
}

struct BeaconTabView: View {
    @StateObject private var transmitter = BeaconTransmitter()
    @State private var majorHex: String = ""
    @State private var minorHex: String = ""

    var body: some View {
        NavigationView {
            Form {
                Section {
                    HStack {
                        Text("Bluetooth")
                        Spacer()
                        Text(transmitter.bluetoothState)
                            .foregroundStyle(.secondary)
                    }
                    HStack {
                        Text("送信状態")
                        Spacer()
                        Text(transmitter.transmissionStatus)
                            .foregroundStyle(transmitter.isTransmitting ? .green : .secondary)
                    }
                } header: {
                    Text("ステータス")
                }

                Section {
                    TextField("Major (4 hex)", text: $majorHex)
                        .textInputAutocapitalization(.none)
                        .autocorrectionDisabled(true)
                        .font(.system(.body, design: .monospaced))
                        .onSubmit(applyMajorMinor)

                    TextField("Minor (4 hex)", text: $minorHex)
                        .textInputAutocapitalization(.none)
                        .autocorrectionDisabled(true)
                        .font(.system(.body, design: .monospaced))
                        .onSubmit(applyMajorMinor)
                } header: {
                    Text("Major / Minor (hex)")
                }

                Section {
                    Toggle("raw iBeacon manufacturer を使う", isOn: $transmitter.useRawIBeaconAdvertising)
                } header: {
                    Text("オプション")
                }

                Section {
                    HStack {
                        Button {
                            transmitter.startTransmitting()
                        } label: {
                            Label("発信開始", systemImage: "play.fill")
                        }
                        .disabled(transmitter.isTransmitting || transmitter.bluetoothState != "Powered On")

                        Button {
                            transmitter.stopTransmitting()
                        } label: {
                            Label("停止", systemImage: "stop.fill")
                        }
                        .disabled(!transmitter.isTransmitting)
                    }
                } header: {
                    Text("操作")
                }
            }
            .navigationTitle("Beacon")
            .onAppear(perform: syncFieldsFromModel)
        }
    }

    private func syncFieldsFromModel() {
        let newMajor = UInt16.random(in: 0...UInt16.max)
        let newMinor = UInt16.random(in: 0...UInt16.max)
        transmitter.major = newMajor
        transmitter.minor = newMinor
        majorHex = String(format: "%04X", newMajor)
        minorHex = String(format: "%04X", newMinor)
    }

    private func applyMajorMinor() {
        let cleanedMajor = majorHex.trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "0x", with: "")
            .uppercased()
        if let value = UInt16(cleanedMajor, radix: 16) {
            transmitter.major = value
            majorHex = String(format: "%04X", value)
        }

        let cleanedMinor = minorHex.trimmingCharacters(in: .whitespacesAndNewlines)
            .replacingOccurrences(of: "0x", with: "")
            .uppercased()
        if let value = UInt16(cleanedMinor, radix: 16) {
            transmitter.minor = value
            minorHex = String(format: "%04X", value)
        }
    }
}

iBeacon 形式で発信する

iBeacon を発信するときは CBPeripheralManager, CLBeaconRegion, CBPeripheralManagerDelegate を使います。iBeacon 自体は、Manufacturer Data と同じなので、実は startAdvertisingRawIBeacon 関数のように、Manufacturer Data を手作りして startAdvertising することも可能なのですが、実はできません。iOS では、自由なデータを送信することができなくて、アドバタイズは iBeacon 形式のみに限られています。

class BeaconTransmitter: NSObject, ObservableObject {
    private var peripheralManager: CBPeripheralManager?
    private var beaconRegion: CLBeaconRegion?
    // 実験用: raw iBeacon manufacturer data を使って広告するかどうか
    @Published var useRawIBeaconAdvertising = false
    // 保持しておくアドバタイズデータ(デバッグ用)
    private var lastAdvertisementData: [String: Any]?

    @Published var isTransmitting = false
    @Published var transmissionStatus = "停止中"
    @Published var bluetoothState = "Unknown"
    @Published var major: UInt16 = 0
    @Published var minor: UInt16 = 0

    // デフォルトのiBeacon設定
    private let defaultUUID = UUID(uuidString: "90FA7ABE-FAB6-485E-B700-1A17804CAA13")!
    private let defaultIdentifier = "FolkBearsBeacon"

    override init() {
        super.init()
        setupPeripheralManager()
    }

    private func setupPeripheralManager() {
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func startTransmitting() {
        guard let peripheralManager = peripheralManager,
              peripheralManager.state == .poweredOn,
              !isTransmitting else {
            print("Bluetooth が利用できないか、既に発信中です")
            return
        }

        let major = CLBeaconMajorValue(major)
        let minor = CLBeaconMinorValue(minor)

        // ビーコンリージョンを作成
        beaconRegion = CLBeaconRegion(
            uuid: defaultUUID,
            major: major,
            minor: minor,
            identifier: defaultIdentifier
        )

        guard let region = beaconRegion else { return }

        // アドバタイズメントデータを生成
        if useRawIBeaconAdvertising {
            // raw manufacturer data を作成して startAdvertising する
            startAdvertisingRawIBeacon(uuid: defaultUUID, major: UInt16(major), minor: UInt16(minor), txPower: -59)
        } else {
            // measuredPowerを明示的に設定(-59dBmが一般的)
            let peripheralData = region.peripheralData(withMeasuredPower: -59 as NSNumber)
            // 保持しておく(デバッグ)
            if let adv = peripheralData as? [String: Any] {
                lastAdvertisementData = adv
            }
            // アドバタイズ開始
            peripheralManager.startAdvertising(peripheralData as? [String: Any])
        }

        isTransmitting = true
        transmissionStatus = "発信中..."
        let majorHex = String(format: "%04X", major)
        let minorHex = String(format: "%04X", minor)
        print("📡 iBeacon 発信開始")
        print("   UUID: \(defaultUUID)")
        print("   Major: \(majorHex)")
        print("   Minor: \(minorHex)")
        print("   Measured Power: -59dBm")

        // デバッグ用:アドバタイズメントデータを表示
        if useRawIBeaconAdvertising {
            if let adv = lastAdvertisementData {
                print("   Advertisement Data (raw manufacturer used): \(adv)")
            } else {
                print("   Advertisement Data: (raw manufacturer advertising active)")
            }
        } else if let advData = lastAdvertisementData {
            print("   Advertisement Data: \(advData)")
        }
    }

    // MARK: - Raw iBeacon (manufacturer data) 広告(実験用)
    /// iBeacon の manufacturer data を手作りして広告を行う(実験用)
    private func startAdvertisingRawIBeacon(uuid: UUID, major: UInt16, minor: UInt16, txPower: Int8 = -59) {
        // iBeacon フォーマット: Apple company id (0x004C little-endian), 0x02, 0x15, UUID(16), major(2), minor(2), tx(1)
        var data = Data()
        // Apple company ID (0x004C) little-endian
        data.append(0x4C)
        data.append(0x00)
        // iBeacon type and length
        data.append(0x02)
        data.append(0x15)

        // UUID bytes (big-endian order as raw bytes of UUID)
        withUnsafeBytes(of: uuid.uuid) { (bytes: UnsafeRawBufferPointer) in
            data.append(contentsOf: bytes)
        }

        // major (big endian)
        data.append(UInt8((major >> 8) & 0xFF))
        data.append(UInt8(major & 0xFF))
        // minor (big endian)
        data.append(UInt8((minor >> 8) & 0xFF))
        data.append(UInt8(minor & 0xFF))
        // tx power
        data.append(UInt8(bitPattern: txPower))

        let adv: [String: Any] = [CBAdvertisementDataManufacturerDataKey: data]
        // デバッグ用に保持と表示
        lastAdvertisementData = adv
        print("📡 iBeacon (raw) 発信データ生成: manufacturerData length=\(data.count)")

        peripheralManager?.startAdvertising(adv)
    }

    func stopTransmitting() {
        guard let peripheralManager = peripheralManager,
              isTransmitting else { return }

        peripheralManager.stopAdvertising()

        isTransmitting = false
        transmissionStatus = "停止中"
        print("iBeacon 発信停止")
    }

    func updateBeaconParameters(major: CLBeaconMajorValue? = nil, minor: CLBeaconMinorValue? = nil) {
        if let major = major { self.major = major }
        if let minor = minor { self.minor = minor }

        let newMajor = CLBeaconMajorValue(self.major)
        let newMinor = CLBeaconMinorValue(self.minor)

        if isTransmitting {
            stopTransmitting()
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.startTransmitting()
            }
        }
        print("ビーコンパラメータ更新 - Major: \(newMajor), Minor: \(newMinor)")
    }
}
extension BeaconTransmitter: CBPeripheralManagerDelegate {
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        DispatchQueue.main.async {
            switch peripheral.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopTransmitting()
                print("Bluetooth が無効です")
            case .resetting:
                self.bluetoothState = "Resetting"
                print("Bluetooth リセット中")
            case .unauthorized:
                self.bluetoothState = "Unauthorized"
                print("Bluetooth 使用権限がありません")
            case .unsupported:
                self.bluetoothState = "Unsupported"
                print("Bluetooth がサポートされていません")
            case .unknown:
                self.bluetoothState = "Unknown"
                print("Bluetooth 状態不明")
            @unknown default:
                self.bluetoothState = "Unknown"
                break
            }
        }
    }

    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        DispatchQueue.main.async {
            if let error = error {
                print("❌ アドバタイズ開始エラー: \(error.localizedDescription)")
                self.transmissionStatus = "エラー: \(error.localizedDescription)"
                self.isTransmitting = false
            } else {
                print("✅ アドバタイズ開始成功")
                print("   状態: Advertising")
                print("   確認: Android側でスキャンを開始してください")
                self.transmissionStatus = "発信中"
            }
        }
    }

    func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
        print("🔄 PeripheralManagerの準備完了")
    }
}

デバイス名の発見アドバタイズ

GattAdvertise で、Device Name をアドバタイズするパターンも作っています。これは GATT 接続の前に発信されるアドバタイズです。
デバイス名だけを送信(実はデバイス名自体はいらないのですが)するだけなので、CBPeripheralManager と CBPeripheralManagerDelegate だけで十分です。アドバタイズデータ advertisementData では、CBAdvertisementDataServiceUUIDsKey と CBAdvertisementDataLocalNameKey のみが有効になります。他のキーを設定しても無視されます。

class GattAdvertise: NSObject, ObservableObject {
    private var peripheralManager: CBPeripheralManager?
    private var customService: CBMutableService?
    
    @Published var isAdvertising = false
    @Published var advertisingStatus = "停止中"
    @Published var bluetoothState = "Unknown"
    @Published var connectedCentrals: [CBCentral] = []
    
    // カスタムサービスとキャラクタリスティックのUUID
    private let serviceUUID = CBUUID(string: "90FA7ABE-FAB6-485E-B700-1A17804CAA13")
    private let characteristicUUID = CBUUID(string: "90FA7ABE-FAB6-485E-B700-1A17804CAA14")
    private let deviceName = "FolkBears-GATT"
    
    private var customCharacteristic: CBMutableCharacteristic?
    private var characteristicValue = "Hello GATT World!"
    
    override init() {
        super.init()
        setupPeripheralManager()
    }
    
    private func setupPeripheralManager() {
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }
    
    func startAdvertising() {
        guard let peripheralManager = peripheralManager,
              peripheralManager.state == .poweredOn,
              !isAdvertising else {
            print("Bluetooth が利用できないか、既にアドバタイズ中です")
            return
        }
        
        let advertisementData: [String: Any] = [
            CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
            CBAdvertisementDataLocalNameKey: deviceName
        ]
        
        peripheralManager.startAdvertising(advertisementData)
        
        isAdvertising = true
        advertisingStatus = "アドバタイズ中..."
        print("GATT アドバタイズ開始 - サービス: \(serviceUUID)")
    }
    
    func stopAdvertising() {
        guard let peripheralManager = peripheralManager,
              isAdvertising else { return }
        
        peripheralManager.stopAdvertising()
        peripheralManager.removeAllServices()
        
        isAdvertising = false
        advertisingStatus = "停止中"
        connectedCentrals.removeAll()
        print("GATT アドバタイズ停止")
    }
    
    func getConnectionSummary() -> String {
        return """
        アドバタイズ状態: \(advertisingStatus)
        接続中デバイス数: \(connectedCentrals.count)個
        サービスUUID: \(serviceUUID)
        デバイス名: \(deviceName)
        """
    }
}

// MARK: - CBPeripheralManagerDelegate
extension GattAdvertise: CBPeripheralManagerDelegate {
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        DispatchQueue.main.async {
            switch peripheral.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopAdvertising()
                print("Bluetooth が無効です")
            case .resetting:
                self.bluetoothState = "Resetting"
                print("Bluetooth リセット中")
            case .unauthorized:
                self.bluetoothState = "Unauthorized"
                print("Bluetooth 使用権限がありません")
            case .unsupported:
                self.bluetoothState = "Unsupported"
                print("Bluetooth がサポートされていません")
            case .unknown:
                self.bluetoothState = "Unknown"
                print("Bluetooth 状態不明")
            @unknown default:
                self.bluetoothState = "Unknown"
                break
            }
        }
    }
    
    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        DispatchQueue.main.async {
            if let error = error {
                print("アドバタイズ開始エラー: \(error.localizedDescription)")
                self.advertisingStatus = "エラー"
                self.isAdvertising = false
            } else {
                print("アドバタイズ開始成功")
                self.advertisingStatus = "アドバタイズ中"
            }
        }
    }
}

16 bit Service UUID 形式の発信

EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンですが、iOS では送信できません。送信はできないのですが、確認のためにコードは作ってあります。Android の受信機で受信できないことを確認してください。
たまに、Android/iOS の受信機に到達することがあり(このときのアドバタイズデータはランダム値になっています)、微妙な感じがするのですが、使えないのは確かです。

class ENSimTransmitter: NSObject, ObservableObject {
	private var peripheralManager: CBPeripheralManager?
	private let serviceUUID = CBUUID(string: "FD6F") // Exposure Notification 16-bit UUID
    private let altServiceUUID = CBUUID(string: "FF00") // Alternative UUID for testing

	@Published var isTransmitting = false
	@Published var transmissionStatus = "停止中"
	@Published var bluetoothState = "Unknown"
	@Published var localName = "ENSim"
    @Published var useAltService: Bool = false
	@Published var rpi: Data = ENSimTransmitter.generateRandomRpi()

	override init() {
		super.init()
		setupPeripheralManager()
	}

	private func setupPeripheralManager() {
		peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
	}

	func startTransmitting() {
		guard let manager = peripheralManager else {
			print("PeripheralManager が初期化されていません")
			return
		}

		guard manager.state == .poweredOn else {
			print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
			return
		}

		guard !isTransmitting else {
			print("既にアドバタイズ中です")
			return
		}

        let selectedService = useAltService ? altServiceUUID : serviceUUID
        let serviceData: [CBUUID: Data] = [selectedService: rpi]

		let advertisementData: [String: Any] = [
			CBAdvertisementDataServiceUUIDsKey: [selectedService],
			CBAdvertisementDataLocalNameKey: localName,
			// CBAdvertisementDataServiceDataKey: serviceData
		]

		manager.startAdvertising(advertisementData)

		isTransmitting = true
		transmissionStatus = "発信中..."

		print("📡 EN シミュレーション発信開始")
		print("   Service UUID (16-bit): \(useAltService ? altServiceUUID.uuidString : serviceUUID.uuidString)")
		print("   Local Name: \(localName)")
		print("   RPI (hex): \(rpi.map { String(format: "%02X", $0) }.joined())")
	}

	func stopTransmitting() {
		guard let manager = peripheralManager, isTransmitting else { return }

		manager.stopAdvertising()
		isTransmitting = false
		transmissionStatus = "停止中"

		print("EN シミュレーション発信停止")
	}

	private static func generateRandomRpi() -> Data {
		
		// let bytes = (0..<16).map { _ in UInt8.random(in: 0...255) }

		// ランダムな uuid を生成して RPI として使用(デバッグ用)
		// 送信は成功するが、Service Data の内容はランダム値になってしまうので、
		// 実質利用ができない。
		let uuid = UUID()
		let uuidBytes = withUnsafeBytes(of: uuid.uuid) { Array($0) }
		let bytes = Array(uuidBytes.prefix(16))
		return Data(bytes)
	}
}

Manufacturer Data 形式の発信

自由な形式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのですが、これも iOS では使えません。これも、使えないことを確認するためにコードを作ってあります。
先に書いた通り、startAdvertisingRawIBeacon 関数を作ってもデータは送信できません。

/// Advertises custom manufacturer data (often consumed as scan response data on the scanner side).
/// フォーマット: [0]=0x02 (type), [1]=0x10 (length=16), [2..17]=TempId(16byte)

class ManufacturerDataTransmitter: NSObject, ObservableObject {
	private var peripheralManager: CBPeripheralManager?

	@Published var isTransmitting = false
	@Published var transmissionStatus = "停止中"
	@Published var bluetoothState = "Unknown"
	@Published var localName: String = "MFG"

	/// 16-bit company identifier (Little Endian in the payload). Default: 0xFFFF for testing.
	@Published var companyId: UInt16 = 0xFFFF
    let beacon_type = 0x02
    let beacon_length = 0x10

	/// Arbitrary manufacturer payload. Default 16 zero bytes for easy overriding.
	@Published var tempIdBytes: Data = Data(repeating: 0x00, count: 16)

	/// Last advertisement dictionary for debugging.
	private(set) var lastAdvertisementData: [String: Any]? = nil

	override init() {
		super.init()
		setupPeripheralManager()
	}

	private func setupPeripheralManager() {
		peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
	}

	/// Start advertising manufacturer data. Uses CBAdvertisementDataManufacturerDataKey which may appear in scan response on the scanner side depending on size and platform rules.
	func startTransmitting() {
		guard let manager = peripheralManager else {
			print("PeripheralManager が初期化されていません")
			return
		}

		guard manager.state == .poweredOn else {
			print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
			return
		}

		guard !isTransmitting else {
			print("既にアドバタイズ中です")
			return
		}

		// Build manufacturer data: company ID (little endian) + payload.
		var mfgData = Data()
		mfgData.append(UInt8(companyId & 0xFF))
		mfgData.append(UInt8((companyId >> 8) & 0xFF))
        mfgData.append(UInt8(beacon_type))
		mfgData.append(UInt8(beacon_length))
        mfgData.append(tempIdBytes)
		let advertisementData: [String: Any] = [
			CBAdvertisementDataManufacturerDataKey: mfgData,
			CBAdvertisementDataLocalNameKey: localName
		]

		lastAdvertisementData = advertisementData
		manager.startAdvertising(advertisementData)

		isTransmitting = true
		transmissionStatus = "発信中..."

		print("📡 Manufacturer 発信開始")
		print(String(format: "   Company ID: 0x%04X (LE)", companyId))
		print("   tempIdBytes (hex): \(tempIdBytes.map { String(format: "%02X", $0) }.joined())")
		print("   Local Name: \(localName)")
	}

	func stopTransmitting() {
		guard let manager = peripheralManager, isTransmitting else { return }

		manager.stopAdvertising()
		isTransmitting = false
		transmissionStatus = "停止中"

		print("Manufacturer 発信停止")
	}
}

実行

左から

  • Android で受信
  • iPhone で受信
  • iPhone で発信

という状態です。iBeacon の UUID は同じなので、major と minor で判断をします。

iBeacon 受発信の様子

受信回数は5分以内に受信したビーコンの回数を数えているので、5分程度放置しておくと1分あたりの受信頻度が計算できます。実際は、平均等を考えないといけないので、もう少し統計データとして保存できるようにしないといけないのですが。

iOS の受信機では1秒に1回程度、Androidの受信機では1秒に5回程度受信しています。たぶん、iOS 受信機のほうで間引いていると思うのですが、このあたりは後に検証します。

  • Android : 5分で 1210 回 = 1秒あたり 4.03 回
  • iOS : 5分で 300 回 = 1秒あたり 1 回

5分後

参考コード

folkbears-transmitter-ios https://github.com/FolkBearsGroup/ble-tools/tree/master/folkbears-transmitter-ios

カテゴリー: 開発, FolkBears | iPhone(iOS) で iBeacon と EN API 発信機を作る はコメントを受け付けていません