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 ツールを Claude Code で作る はコメントを受け付けていません

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-chat ツールを GitHub Copilot で作る…が、途中で断念 はコメントを受け付けていません

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 に読み込ませてコーディングをしていきます。

カテゴリー: 開発 | BLE5 の Extended Advertising を使って AI コーディングを試していく はコメントを受け付けていません

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 ってのは聞いてはいるけど、いまいち何ができるのか分からない、という人にも二番目におすすめします。

カテゴリー: 開発 | Google 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 発信機を作る はコメントを受け付けていません

Android で iBeacon と EN API 発信機を作る

iBeacon の受信機ができたので、逆に発信機を作っていきます。

Android で iBeacon と EN API 受信機を作る

これを組み合わせると Android/iOS の相互に iBeacon やその他の BLE の送受信状態が確認できます。通常の受発信はあちこちのブログにあるので参照できます。私が確認していきたいのは、

  • 受信タイミング(Scan Window/Scan Interval)が異なる場合
  • 発信側の発信タイミング(Advertising Interval)が異なる場合

の組み合わせです。これは実測するとわかりますが、両方とも LOW POWER で動かすと、iBeacon の受信がなかなか発生しないという現象が発生します。場合によっては 1 分位待たされることがあります。この現象を実測するためのツールです。
Android では細かい設定はできないのですが、先行きは m5stack などを使って細かくチェックしていく予定です。

Jetpack Compose で作る

受信機と同じように、Jetpack Compose を使います。
だんだん、複雑怪奇になってくるのですが、タブ切り替えで iBeacon を発信します。ここのタブだけ、権限の確認とリクエストも入れています。

@Composable
private fun IBeaconTransmitterTab(
    advertiseMode: Int,
    advertiseTxPowerLevel: Int,
    onAdvertiseModeChange: (Int) -> Unit,
    onAdvertiseTxPowerChange: (Int) -> Unit,
) {
    val context = LocalContext.current
    var majorHex by rememberSaveable { mutableStateOf((0..0xFFFF).random().toString(16).uppercase().padStart(4, '0')) }
    var minorHex by rememberSaveable { mutableStateOf((0..0xFFFF).random().toString(16).uppercase().padStart(4, '0')) }
    val transmitter = remember(majorHex, minorHex) { BeaconTransmitter(context, major = majorHex.toIntOrNull(16) ?: 0, minor = minorHex.toIntOrNull(16) ?: 0) }
    var isAdvertising by rememberSaveable { mutableStateOf(false) }

    fun restartIfAdvertising() {
        if (isAdvertising) {
            transmitter.stopTransmitter()
            transmitter.advertiseMode = advertiseMode
            transmitter.advertiseTxPowerLevel = advertiseTxPowerLevel
            transmitter.startTransmitter()
        }
    }

    var hasPermission by remember { mutableStateOf(hasScanPermissions(context)) }
    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { result ->
        hasPermission = result.values.all { it }
    }

    // Collect scan results
    if (!hasPermission) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp)
        ) {
            Text(
                text = "Bluetoothスキャン権限が必要です。許可してください。",
                style = MaterialTheme.typography.bodyLarge
            )
            Button(
                onClick = {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                        permissionLauncher.launch(
                            arrayOf(
                                android.Manifest.permission.BLUETOOTH_ADVERTISE,
                                android.Manifest.permission.BLUETOOTH_CONNECT,
                            )
                        )
                    } else {
                        permissionLauncher.launch(
                            arrayOf(
                                android.Manifest.permission.BLUETOOTH,
                                android.Manifest.permission.BLUETOOTH_ADMIN,
                            )
                        )
                    }
                },
                modifier = Modifier.padding(top = 12.dp)
            ) {
                Text("権限をリクエスト")
            }
        }
        return
    }

    Column(modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)) {
        Text(text = if (isAdvertising) "iBeacon 発信中" else "iBeacon 停止中", style = MaterialTheme.typography.titleMedium)

        Row(modifier = Modifier.padding(top = 12.dp)) {
            OutlinedTextField(
                value = majorHex,
                onValueChange = { majorHex = it.filterHex(limit = 4) },
                label = { Text("Major (hex)") },
                singleLine = true,
                modifier = Modifier.weight(1f)
            )
            OutlinedTextField(
                value = minorHex,
                onValueChange = { minorHex = it.filterHex(limit = 4) },
                label = { Text("Minor (hex)") },
                singleLine = true,
                modifier = Modifier
                    .weight(1f)
                    .padding(start = 8.dp)
            )
        }

        AdvertiseSettingRow(
            advertiseMode = advertiseMode,
            advertiseTxPowerLevel = advertiseTxPowerLevel,
            onAdvertiseModeChange = { mode ->
                onAdvertiseModeChange(mode)
                transmitter.advertiseMode = mode
                restartIfAdvertising()
            },
            onAdvertiseTxPowerChange = { level ->
                onAdvertiseTxPowerChange(level)
                transmitter.advertiseTxPowerLevel = level
                restartIfAdvertising()
            }
        )

        Row(modifier = Modifier.padding(top = 12.dp)) {
            Switch(
                checked = isAdvertising,
                onCheckedChange = { checked ->
                    if (checked) {
                        transmitter.major = majorHex.toIntOrNull(16) ?: 0
                        transmitter.minor = minorHex.toIntOrNull(16) ?: 0
                        transmitter.advertiseMode = advertiseMode
                        transmitter.advertiseTxPowerLevel = advertiseTxPowerLevel
                        transmitter.startTransmitter()
                    } else {
                        transmitter.stopTransmitter()
                    }
                    isAdvertising = checked
                }
            )
            Text(
                text = if (isAdvertising) "ON" else "OFF",
                modifier = Modifier.padding(start = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }

        Text(
            text = "UUID: ${BeaconTransmitter.SERVICE_UUID}",
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(top = 16.dp)
        )
    }
}

iBeaqcon を発信するときに major と minor を指定できるようにしています。

iBeacon の発信

iBeacon の発信は  AltBeacon を使っているのですが、これも Android の BluetoothLeAdvertiser を直接使う方法も検討しています。電文データを作るのは BluetoothLeAdvertiser でもあまり変わらないということと、BLE5 の拡張機能を使うときに、AltBeacon だと対応できない可能性があるためです。Extended Advertising を使うように変更していきます。

class BeaconTransmitter(
    private val context: Context,
    major: Int = 0,
    minor: Int = 0
) {
    
    companion object {
        const val TAG = "BeaconTransmitter"
        val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13")        // FolkBears サービス
    }
    private var beaconTransmitter: org.altbeacon.beacon.BeaconTransmitter? = null
    var major: Int = major
    var minor: Int = minor
    var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
    var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW

    private fun startBeaconTransmission() {
        // Permission check (Android 12+ requires BLUETOOTH_ADVERTISE)
        val advertiseGranted = ContextCompat.checkSelfPermission(
            context,
            android.Manifest.permission.BLUETOOTH_ADVERTISE
        ) == android.content.pm.PackageManager.PERMISSION_GRANTED
        if (!advertiseGranted) {
            Log.e(TAG, "BLUETOOTH_ADVERTISE permission not granted; cannot start advertising")
            return
        }

        val adapter = BluetoothAdapter.getDefaultAdapter()
        if (adapter == null) {
            Log.e(TAG, "BluetoothAdapter not available")
            return
        }
        if (!adapter.isEnabled) {
            Log.e(TAG, "BluetoothAdapter disabled; enable Bluetooth and retry")
            return
        }

        // 以下 org.altbeacon.beacon を利用しない方法も検討

        val support = org.altbeacon.beacon.BeaconTransmitter.checkTransmissionSupported(context)
        if (support != org.altbeacon.beacon.BeaconTransmitter.SUPPORTED) {
            Log.e(TAG, "Beacon transmission not supported: code=$support")
            return
        }

        val beacon = Beacon.Builder()
            .setId1(SERVICE_UUID.toString()) // UUID
            .setId2(major.toString()) // Major (10進数文字列)
            .setId3(minor.toString()) // Minor (10進数文字列)
            .setManufacturer(0x004C) // Apple iBeacon のメーカーコード
            .setTxPower(-59) // 信号強度 (dBm)は仮設定
            .build()

        // val beaconParser = BeaconParser().setBeaconLayout(BeaconParser.ALTBEACON_LAYOUT)
        val beaconParser = BeaconParser().setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24")
        val altBeaconTransmitter = BeaconTransmitter(context, beaconParser).apply {
            advertiseMode = this@BeaconTransmitter.advertiseMode
            advertiseTxPowerLevel = this@BeaconTransmitter.advertiseTxPowerLevel
            isConnectable = false // 非コネクタブルに
        }

        try {
            altBeaconTransmitter?.startAdvertising(beacon, object : AdvertiseCallback() {
                override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
                    Log.d(TAG, "iBeacon 発信開始")
                }
                override fun onStartFailure(errorCode: Int) {
                    Log.e(TAG, "iBeacon 発信に失敗: $errorCode")
                }
            })
        } catch (e: SecurityException) {
            Log.e(TAG, "SecurityException when starting advertising: ${e.message}")
        } catch (e: Throwable) {
            Log.e(TAG, "Unexpected error when starting advertising: ${e.message}")
        }
    }

    ///
    /// @break Beacon の発信開始
    ///
    fun startTransmitter() {
        Log.d(TAG, "startTransmitter")
        if (beaconTransmitter == null ) {
            startBeaconTransmission()
        }
    }
    ///
    /// @brief Beacon の発信停止
    ///
    fun stopTransmitter() {
        Log.d(TAG, "stopTransmitter")
        beaconTransmitter?.stopAdvertising()
        beaconTransmitter = null
    }
}

EN API 形式の発信

EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンです。BluetoothLeAdvertiser を使います。
EN API の 0xFD6F と実験用の 0xFF00 のどちらかで送信できるようにします。
Android の場合は 0xFD6F も 0xFF00 も両方とも受信できます。iOS の場合は 0xFF00 のほうだけが受信できます。

class ENSimTransmitter(
	private val context: Context,
	tempIdBytes: ByteArray = ByteArray(16),
	useAltService: Boolean = false
) {

	companion object {
		const val TAG = "ENSimTransmitter"
		val SERVICE_UUID: UUID = UUID.fromString("0000FD6F-0000-1000-8000-00805F9B34FB")
		val SERVICE_UUID_ALT: UUID = UUID.fromString("0000FF00-0000-1000-8000-00805F9B34FB")
		val SERVICE_DATA_UUID_ALT: UUID = UUID.fromString("00000001-0000-1000-8000-00805F9B34FB")
	}

    var useAltService: Boolean = useAltService
    var tempIdBytes: ByteArray = tempIdBytes
	var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
	var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW

	private var advertiser: BluetoothLeAdvertiser? = null
	private var advertiseCallback: AdvertiseCallback? = null
	@Volatile
	private var isAdvertising = false

	///
	/// ENSim の発信開始
	///
	fun startTransmitter() {
		Log.d(TAG, "startTransmitter")
        startAdvertisingInternal()
	}

	///
	/// ENSim の発信停止
	///
	fun stopTransmitter() {
		Log.d(TAG, "stopTransmitter")
		advertiser?.stopAdvertising(advertiseCallback)
	}

	private fun startAdvertisingInternal() {
        if ( advertiser == null ) {
            val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            val adapter = bluetoothManager.adapter
            advertiser = adapter.bluetoothLeAdvertiser
        }
		val adv = advertiser ?: run {
			Log.e(TAG, "BluetoothLeAdvertiser を取得できませんでした")
			return
		}

		val settings = AdvertiseSettings.Builder()
			.setAdvertiseMode(advertiseMode)
			.setTxPowerLevel(advertiseTxPowerLevel)
			.setConnectable(false)
			.build()

		val targetUuid = if (useAltService) SERVICE_UUID_ALT else SERVICE_UUID
		val dataUuidForPayload = if (useAltService) SERVICE_DATA_UUID_ALT else targetUuid

		val data = AdvertiseData.Builder()
			.setIncludeDeviceName(false)
			.setIncludeTxPowerLevel(true)
			.addServiceUuid(ParcelUuid(targetUuid))
			.addServiceData(ParcelUuid(dataUuidForPayload), tempIdBytes)
			.build()

		advertiseCallback = object : AdvertiseCallback() {
			override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
				super.onStartSuccess(settingsInEffect)
				isAdvertising = true
				Log.d(TAG, "ENSim advertise start")
			}

			override fun onStartFailure(errorCode: Int) {
				super.onStartFailure(errorCode)
				isAdvertising = false
				Log.e(TAG, "ENSim advertise failed: $errorCode")
			}
		}

		try {
			adv.startAdvertising(settings, data, advertiseCallback)
		} catch (e: Exception) {
			isAdvertising = false
			Log.e(TAG, "startAdvertising exception: ${e.message}")
		}
	}

	private fun String.toByteArrayFromHex(): ByteArray {
		if (length % 2 != 0) return ByteArray(0)
		return chunked(2)
			.mapNotNull { it.toIntOrNull(16)?.toByte() }
			.toByteArray()
	}
}

デバイス発見のためのアドバタイズ発信

GATT 接続をするためには、まずはデバイスを発見しなければいけません。その発見部分だけを発信します。これは、FolkBears のコネクション版を作っていたときに、なかなかデバイスが発見できないところの原因を掴むためのツールです。実際のところ、デバイス名発信と変わらない(iBeacon とも変わらない)現象が発生します。つまりは、発信タイミングと受信タイミングの組み合わせによって、デバイスが発見されるまでの時間が大きく変わります。
発見した後は、おそらく通常に GATT サービスを接続できるはずです。

class GattAdvertise(
    private val context: Context
)
{
    companion object {
        const val TAG = "GattAdvertise"
        val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13")        // FolkBears サービス
    }

    private var advertiser: BluetoothLeAdvertiser? = null
    @Volatile
    var isAdvertising = false
    private var lastStopTime = 0L
    private var backgroundRetryRunnable: Runnable? = null

    var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
    var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW


    private var currentCallback: AdvertiseCallback? = null

    private fun createAdvertiseCallback(): AdvertiseCallback {
        return object : AdvertiseCallback() {
            override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
                super.onStartSuccess(settingsInEffect)
                Log.d(TAG, "Advertising onStartSuccess")
                isAdvertising = true
            }

            override fun onStartFailure(errorCode: Int) {
                super.onStartFailure(errorCode)
                val reason: String

                when (errorCode) {
                    ADVERTISE_FAILED_ALREADY_STARTED -> {
                        Log.w(TAG, "Advertising already started on Android ${Build.VERSION.SDK_INT}, forcing stop and retry")
                        return
                    }
                    ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> {
                        reason = "ADVERTISE_FAILED_FEATURE_UNSUPPORTED"
                        isAdvertising = false
                    }
                    ADVERTISE_FAILED_INTERNAL_ERROR -> {
                        reason = "ADVERTISE_FAILED_INTERNAL_ERROR"
                        isAdvertising = false
                    }
                    ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
                        reason = "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"
                        isAdvertising = false
                    }
                    ADVERTISE_FAILED_DATA_TOO_LARGE -> {
                        reason = "ADVERTISE_FAILED_DATA_TOO_LARGE"
                        isAdvertising = false
                    }
                    else -> {
                        reason = "UNDOCUMENTED"
                        isAdvertising = false
                    }
                }
                Log.d(TAG, "Advertising onStartFailure: $errorCode - $reason")
            }
        }
    }

    private var data: AdvertiseData? = null

    fun startAdvertising() {

        if (  advertiser == null ) {
            val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            val adapter = bluetoothManager.adapter
            advertiser = adapter.bluetoothLeAdvertiser
        }   

        if (isAdvertising) {
            Log.d(TAG, "Already advertising or starting: advertising=$isAdvertising")
            return
        }

        data = AdvertiseData.Builder()
            .setIncludeDeviceName(false)
            .setIncludeTxPowerLevel(true)
            .addServiceUuid(ParcelUuid(SERVICE_UUID))
            .build()

        currentCallback = createAdvertiseCallback()
        val settings = AdvertiseSettings.Builder()
            .setTxPowerLevel(advertiseTxPowerLevel)
            .setAdvertiseMode(advertiseMode)
            .setConnectable(true)
            .build()

        advertiser?.startAdvertising(settings, data, currentCallback)
    }

    fun stopAdvertising() {
        if ( isAdvertising == false ) {
            Log.d(TAG, "Not currently advertising, skipping stop")
            return
        }
        currentCallback?.let { advertiser?.stopAdvertising(it) }
        isAdvertising = false
    }
}

Manufacturer Data 形式の発信

自由な型式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのです。Manufacturer Data 形式の場合は、Android でも iOS でも受信が可能です。
ただし、iOS の場合は、Manufacturer Data 形式での発信ができないので、接触確認アプリ FolkBears の作成には向いていません…が、m5stack などの専用デバイスを作れば結構いけるのではないか、と思っています。その場合は、16 bit Service UUID を使う方法もあるのですが。

Manufacturer Data は自由に作れるのですが、iBeacon っぽく先頭に beacon_type と beacon_length を入れてあります。このあたり、Android で使われていた AltBeacon 形式でも構いません。相互運用を考えなければ、独自フォーマットで十分だと思います。

class ManufacturerDataTransmitter(
	private val context: Context,
	manufacturerId: Int = 0xFFFF,
    tempIdBytes: ByteArray = ByteArray(16)
) {

	companion object {
		const val TAG = "ManufacturerDataTx"
	}

    var tempIdBytes: ByteArray = tempIdBytes
    var manufacturerId: Int = manufacturerId
	var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
	var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW

	private var advertiser: BluetoothLeAdvertiser? = null
	private var advertiseCallback: AdvertiseCallback? = null
	@Volatile
	private var isAdvertising = false
	@Volatile
	private var payload: ByteArray = ByteArray(0)

	init {
		// TempId を事前にロード
		CoroutineScope(Dispatchers.IO).launch {
			payload = buildPayload()
		}
	}

	///
	/// Manufacturer Data 発信開始
	///
	fun startTransmitter() {
		Log.d(TAG, "startTransmitter")
		if (isAdvertising) return
        startAdvertisingInternal()
	}

	///
	/// Manufacturer Data 発信停止
	///
	fun stopTransmitter() {
		Log.d(TAG, "stopTransmitter")
		advertiser?.stopAdvertising(advertiseCallback)
		advertiseCallback = null
		isAdvertising = false
	}

	private fun startAdvertisingInternal() {
        if ( advertiser == null ) {
            val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            val adapter = bluetoothManager.adapter
            advertiser = adapter.bluetoothLeAdvertiser
        }

		val adv = advertiser ?: run {
			Log.e(TAG, "BluetoothLeAdvertiser を取得できませんでした")
			return
		}

		val settings = AdvertiseSettings.Builder()
			.setAdvertiseMode(advertiseMode)
			.setTxPowerLevel(advertiseTxPowerLevel)
			.setConnectable(false)
			.build()

		val data = AdvertiseData.Builder()
			.setIncludeDeviceName(false)
			.setIncludeTxPowerLevel(true)
			.addManufacturerData(manufacturerId, payload)
			.build()

		advertiseCallback = object : AdvertiseCallback() {
			override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
				super.onStartSuccess(settingsInEffect)
				isAdvertising = true
				Log.d(TAG, "Manufacturer advertise start (id=0x${manufacturerId.toString(16)})")
			}

			override fun onStartFailure(errorCode: Int) {
				super.onStartFailure(errorCode)
				isAdvertising = false
				Log.e(TAG, "Manufacturer advertise failed: $errorCode")
			}
		}

		try {
			adv.startAdvertising(settings, data, advertiseCallback)
		} catch (e: Exception) {
			isAdvertising = false
			Log.e(TAG, "startAdvertising exception: ${e.message}")
		}
	}

	private suspend fun buildPayload(): ByteArray {
		val currentTime = System.currentTimeMillis()
		if (tempIdBytes.size < 16) return ByteArray(0)

		// 0x02(type), 0x10(length=16), then 16-byte tempId
		val payload = ByteArray(2 + 16)
		payload[0] = 0x02
		payload[1] = 0x10
		tempIdBytes.copyInto(destination = payload, destinationOffset = 2, endIndex = 16)
		return payload
	}

	private fun String.toByteArrayFromHex(): ByteArray {
		if (length % 2 != 0) return ByteArray(0)
		return chunked(2)
			.mapNotNull { it.toIntOrNull(16)?.toByte() }
			.toByteArray()
	}
}

動作確認

実際の動きは、

  • 受信側を Low Power に固定
  • 発信側を Low Power と Low Latency で切り替える

Low Power で発信

Low Latency で発信

実測すると、受発信のどちらかが Low Latency になっていると受信の遅延は少ないのですが、両方とも Low Power になっていると、たまに受信が遅くなることがあります。これは後で計算式を出して実測します。

あと、Android の場合は BLE 5 の Extended Advertising を使うことができるので、startAdvertisingSet を使って発信の方を細かく設定していきます。

参考コード

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

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

iPhone(iOS)で Manufacturer Data を受信する

前回の続きで、FolkBears 型と Manufacturer Data 形式の受信機を iOS 版も作っていきます。FolkBears 型というのは GATT サービスで接続する方式のことです。実際のところは、コネクションして TempUserId を取得するのですが、ひとまず相手のデバイスを見つけるところまで実装しておきます。このあたり、コネクション型で接触確認アプリを作ろうとすると、TempUserId の交換よりも、最初に相手のデバイスを見つけるところで接触遅延するのではないか、という懸念があるためです。これを後に実測していきます。

GATT 通信のために相手デバイスの発見

centralManager.scanForPeripherals を呼び出すときに、重複をゆるすようにして CBCentralManagerScanOptionAllowDuplicatesKey = true を指定します。通常は、ペリフェラルの発見は一度だけでよいのですが、今回のモニタリングの場合はデバイスとの接触頻度をみるためにわざと重複させるようにします。

    func startScanning() {
        guard let centralManager = centralManager,
              centralManager.state == .poweredOn,
              !isScanning else {
            print("Bluetooth が利用できないか、既にスキャン中です")
            return
        }
        
        peripherals.removeAll()
        // discoveredPeripherals.removeAll()
        
        // 特定のサービスUUIDでスキャン(nilで全デバイス)
        centralManager.scanForPeripherals(withServices: [targetServiceUUID], options: [
            CBCentralManagerScanOptionAllowDuplicatesKey: true // 重複を許可してスキャン
        ])
        
        isScanning = true
        scanningStatus = "スキャン中..."
        print("GATT クライアント スキャン開始")
    }

FolkBears 本体からコードを抜き出してきたので、無駄なコードが多いですが、CBCentralManagerDelegate の centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) で相手のデバイスが発見しているイベントです。

最小コードとしては、先の CBCentralManagerScanOptionAllowDuplicatesKey: true の設定と、CBCentralManagerDelegate の centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) の実装だけで十分です。

extension GattClient: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        DispatchQueue.main.async {
            switch central.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopScanning()
                self.disconnectFromPeripheral()
                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 centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        
        let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
        
        let discoveredPeripheral = DiscoveredPeripheral(
            peripheral: peripheral,
            name: name,
            rssi: RSSI,
            advertisementData: advertisementData
        )
        
        DispatchQueue.main.async {
            self.peripherals.append(discoveredPeripheral)
            self.scanningStatus = "発見: \(self.peripherals.count)個"
            self.onDiscover?(discoveredPeripheral, Date())
        }
        print("ペリフェラル発見: \(discoveredPeripheral.displayName), RSSI: \(RSSI)")
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        DispatchQueue.main.async {
            self.connectedPeripheral = peripheral
            self.connectionStatus = "接続済み"
        }
        
        print("ペリフェラル接続成功: \(peripheral.name ?? "Unknown")")
        
        // MTU要求を実行
        requestMTU(requestedMTU)
        
        // サービス探索開始
        peripheral.discoverServices([targetServiceUUID])
    }
    
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        DispatchQueue.main.async {
            self.connectionStatus = "接続失敗"
        }
        
        print("ペリフェラル接続失敗: \(error?.localizedDescription ?? "Unknown error")")
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        DispatchQueue.main.async {
            self.connectedPeripheral = nil
            self.connectionStatus = "切断済み"
            self.targetService = nil
            self.targetCharacteristic = nil
        }
        
        print("ペリフェラル切断: \(peripheral.name ?? "Unknown")")
    }
}

Manufacturer Data 形式の受信

書き方としては Android のときと同じで CBCentralManagerDelegate だけを使います。 iBeacon のように CLLocationManager と CLBeaconRegion を使いません。

COCOA/EN API のように特定のデータを配信する形は、この Manufacturer Data 形式でやるのが一番いいのですが、後で記事にしますが iOS では Manufacturer Data での発信ができません。Manufacturer Data 形式で発信できるのは iBeacon 形式だけで、実際に発信しようとするとデータ部分がランダム値(?)になってしまうことになります。これは、実際に iOS 用の発信ツールを作ったときに確認します。

final class ManufacturerDataScan: NSObject, ObservableObject {
    /// 受信時のコールバック。keyはCompany ID(16bit)を0xXXXXで表記。
    var onManufacturerData: ((String, Data, NSNumber, CBPeripheral, Data) -> Void)?

    @Published var isScanning = false
    @Published var scanningStatus = "停止中"

    private var centralManager: CBCentralManager!

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func startScan() {
        guard centralManager.state == .poweredOn else {
            print("ManufacturerDataScan: Bluetooth未準備 state=\(centralManager.state.rawValue)")
            return
        }
        guard !isScanning else { return }

        centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
        isScanning = true
        scanningStatus = "スキャン中..."
    }

    func stopScan() {
        guard isScanning else { return }
        centralManager.stopScan()
        isScanning = false
        scanningStatus = "停止中"
    }
}

extension ManufacturerDataScan: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            print("ManufacturerDataScan: Bluetooth On")
        case .unauthorized:
            print("ManufacturerDataScan: unauthorized")
        case .unsupported:
            print("ManufacturerDataScan: unsupported")
        case .poweredOff:
            print("ManufacturerDataScan: Bluetooth Off")
        default:
            break
        }
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        guard let data = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, !data.isEmpty else { return }
        // Company IDは先頭2バイトLittle Endianで格納される
        let companyId = data.prefix(2).reduce(0) { acc, byte in (acc << 8) | Int(byte) }
        let key = String(format: "0x%04X", companyId)
        let beacon_type = data[2]
        let beacon_length = data[3]


        if ( companyId == 0xFFFF ) {
            if ( beacon_type == 0x02 && beacon_length == 0x10 ) {
                let tempid = data.dropFirst(4)
                onManufacturerData?(key, data, RSSI, peripheral, tempid)
            }
        }
    }
}   

実行した様子

参考先

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

カテゴリー: 開発, FolkBears | iPhone(iOS)で Manufacturer Data を受信する はコメントを受け付けていません