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 | コメントする

複数の 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 の受発信が 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 | コメントする

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 | コメントする

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 | コメントする

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)で iBeacon と EN API 受信機を作る

Android で iBeacon/EN API 受信機を作ることができたので、次は iPhone(iOS) で作ってみます。先に書いた通り、Android と iOS では Beacon の受信方法が異なります。さらに、iOS では Scan Window/Scan Interval の細かい動作を指定することができません。
で、Apple 版と Android 版の両方の受信機を作ったときに、どうやら Android のほうが受信頻度がいまいちなんですよね…という確認のために iOS 版を作って比較します。

結論から先に言うと、Android 版のほうは SCAN_MODE_LOW_POWER で動かして省電力化すると、iOS 版と比べて相当受信頻度が落ちます。逆に言えば iOS 版のほうがバッテリーの消耗が激しいということです。このあたりも後に確認したいです。SCAN_MODE_LOW_LATENCY で動かすと、Android 版のほうも受信頻度が上がるのですが、これも iOS 版と比べるとどの位の頻度でいけるのか同程度なのか?ということもいずれ調べていきます。

SwiftUI で作る

FolkBears 本体は従来の storyboard で作ってあるのですが、今は SwiftUI で作るのが楽なので、今回の受信機は SwiftUI で作っていきます。本体 FolkBears も SwiftUI 形式に移行中です。

struct ContentView: View {
    var body: some View {
        TabView {
            IBeaconTabView()
                .tabItem { Label("iBeacon", systemImage: "dot.radiowaves.left.and.right") }

            FolkBearsTabView()
                .tabItem { Label("FolkBears", systemImage: "antenna.radiowaves.left.and.right") }

            EnApiTabView()
                .tabItem { Label("EN API", systemImage: "waveform.path") }

            ManufacturerDataTabView()
                .tabItem { Label("Mfr Data", systemImage: "barcode") }
        }
    }
}

// MARK: - iBeacon
private struct IBeaconTabView: View {
    @StateObject private var scanner = BeaconScan()
    @State private var detectionLog: [(id: String, beacon: CLBeacon, date: Date)] = []
    @State private var summaries: [BeaconSummary] = []

    private let windowSeconds: TimeInterval = 5 * 60

    var body: some View {
        NavigationView {
            VStack(alignment: .leading, spacing: 16) {
                HStack {
                    Text("状態: \(scanner.scanningStatus)")
                    Spacer()
                    Button(scanner.isScanning ? "停止" : "開始") {
                        scanner.isScanning ? scanner.stopScanning() : scanner.startScanning()
                    }
                    .buttonStyle(.borderedProminent)
                }

                if summaries.isEmpty {
                    Text("受信した iBeacon がまだありません")
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity, alignment: .leading)
                } else {
                    List(summaries) { summary in
                        BeaconRow(summary: summary)
                    }
                    .listStyle(.plain)
                }
            }
            .padding()
            .navigationTitle("iBeacon")
            .onAppear {
                scanner.onIBeacon = { beacon, date in
                    addDetection(beacon, at: date)
                }
                if !scanner.isScanning { scanner.startScanning() }
            }
            .onDisappear {
                detectionLog.removeAll()
                summaries.removeAll()
            }
        }
    }

    private func addDetection(_ beacon: CLBeacon, at date: Date) {
        let id = "\(beacon.uuid.uuidString)-\(beacon.major.intValue)-\(beacon.minor.intValue)"
        detectionLog.append((id: id, beacon: beacon, date: date))

        // 5分より古いログを削除
        detectionLog = detectionLog.filter { date.timeIntervalSince($0.date) <= windowSeconds }

        // 集計
        let grouped = Dictionary(grouping: detectionLog, by: { $0.id })
        summaries = grouped.values.compactMap { entries in
            guard let latest = entries.max(by: { $0.date < $1.date }) else { return nil }
            return BeaconSummary(
                id: latest.id,
                uuid: latest.beacon.uuid,
                major: latest.beacon.major.uint16Value,
                minor: latest.beacon.minor.uint16Value,
                rssi: latest.beacon.rssi,
                accuracy: latest.beacon.accuracy,
                proximity: latest.beacon.proximity,
                count: entries.count
            )
        }
        .sorted { $0.count > $1.count }
    }
}

iBeacon を受信する

iOS で iBeacon を使って近接検出する場合には、CLBeaconRegion と CLLocationManager の両方を使います。このあたりの動きは Android と異なるので注意してください。もともと、iBeacon の利用が、店舗などに配置された Beacon を検出する=店内に入ったことを検出するという用途になっているので、Beacon 検出は、ある領域に入った時、あるいは出たときにしかイベントが発生しません。

このために、既に Beacon の領域に入っている時にアプリを立ち上げるとイベントが発生しません。あらかじめ、領域外のところでアプリを立ち上げて、Beacon の領域に入らなくてはいけません。
何故、こんな仕様になっているのか不思議ですが、たまに「店内に入る前にアプリを立ち上げて~」というアナウンスがあるのはこのためでしょう。

FolkBears の受信機では、Android 版のように連続して Beacon を受信して欲しいので、CLLocationManagerDelegate の locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying beaconConstraint: CLBeaconIdentityConstraint) を使って、定期的に受信するようにしています。

class BeaconScan: NSObject, ObservableObject {
    private var locationManager: CLLocationManager
    private var beaconRegion: CLBeaconRegion?
    private var beaconConstraint: CLBeaconIdentityConstraint?

    /// iBeacon検出時に呼ばれるコールバック(UIで集計するため)
    var onIBeacon: ((CLBeacon, Date) -> Void)?
    
    @Published var discoveredBeacons: [CLBeacon] = []
    @Published var isScanning = false
    @Published var scanningStatus = "停止中"
    
    // デフォルトのiBeacon設定
    private let defaultUUID = UUID(uuidString: "90FA7ABE-FAB6-485E-B700-1A17804CAA13")!
    private let defaultIdentifier = "FolkBearsBeacon"
    
    override init() {
        self.locationManager = CLLocationManager()
        super.init()
        setupLocationManager()
    }
    
    private func setupLocationManager() {
        locationManager.delegate = self
        // iBeaconレンジングには「このAppの使用中」以上が必要。バックグラウンド受信する場合は Always も要求する。
        if locationManager.authorizationStatus == .notDetermined {
            locationManager.requestWhenInUseAuthorization()
        }
    }
    
    func startScanning() {
        guard !isScanning else { return }
        
        // ビーコンリージョンを作成
        beaconRegion = CLBeaconRegion(
            uuid: defaultUUID,
            identifier: defaultIdentifier
        )
        beaconConstraint = CLBeaconIdentityConstraint(uuid: defaultUUID)
        
        guard let region = beaconRegion else { return }
        
        // リージョンモニタリング開始
        locationManager.startMonitoring(for: region)

        // すぐにレンジング開始(既にリージョン内にいる場合 didEnterRegion が来ないことがあるため)
        if let constraint = beaconConstraint {
            locationManager.startRangingBeacons(satisfying: constraint)
        }
        
        isScanning = true
        scanningStatus = "スキャン中..."
        print("iBeacon スキャン開始")
    }
    
    func stopScanning() {
        guard isScanning else { return }
        
        if let region = beaconRegion {
            locationManager.stopMonitoring(for: region)
        }

        if let constraint = beaconConstraint {
            locationManager.stopRangingBeacons(satisfying: constraint)
        }
        
        isScanning = false
        scanningStatus = "停止中"
        discoveredBeacons.removeAll()
        print("iBeacon スキャン停止")
    }
}

// MARK: - CLLocationManagerDelegate
extension BeaconScan: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
        guard let beaconRegion = region as? CLBeaconRegion else { return }
        print("ビーコンリージョンに入りました: \(beaconRegion.identifier)")
        
        // レンジング開始
        let constraint = beaconConstraint ?? CLBeaconIdentityConstraint(uuid: beaconRegion.uuid)
        beaconConstraint = constraint
        locationManager.startRangingBeacons(satisfying: constraint)
    }
    
    func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
        guard let beaconRegion = region as? CLBeaconRegion else { return }
        print("ビーコンリージョンから出ました: \(beaconRegion.identifier)")
        
        // レンジング停止
        let constraint = beaconConstraint ?? CLBeaconIdentityConstraint(uuid: beaconRegion.uuid)
        locationManager.stopRangingBeacons(satisfying: constraint)
    }
    
    func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying beaconConstraint: CLBeaconIdentityConstraint) {
        let now = Date()
        let validBeacons = beacons.filter { $0.proximity != .unknown }

        DispatchQueue.main.async {
            self.discoveredBeacons = validBeacons
            self.scanningStatus = "検出: \(validBeacons.count)個"
        }

        for beacon in validBeacons {
            let majorHex = String(format: "%04X", beacon.major.uint16Value)
            let minorHex = String(format: "%04X", beacon.minor.uint16Value)
            print("ビーコン検出 - UUID: \(beacon.uuid), Major: 0x\(majorHex), Minor: 0x\(minorHex), RSSI: \(beacon.rssi), Distance: \(String(format: "%.2f", beacon.accuracy))m")
            DispatchQueue.main.async {
                self.onIBeacon?(beacon, now)
            }
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location Manager エラー: \(error.localizedDescription)")
        DispatchQueue.main.async {
            self.scanningStatus = "エラー"
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedWhenInUse, .authorizedAlways:
            print("位置情報の使用が許可されました")
            // 権限取得後にスキャン指示が出ていた場合、レンジングを開始しておく
            if isScanning {
                let constraint = beaconConstraint ?? CLBeaconIdentityConstraint(uuid: defaultUUID)
                beaconConstraint = constraint
                locationManager.startRangingBeacons(satisfying: constraint)
            }
        case .denied, .restricted:
            print("位置情報の使用が拒否されました")
            DispatchQueue.main.async {
                self.scanningStatus = "位置情報権限が必要"
            }
        case .notDetermined:
            print("位置情報の権限が未確定")
        @unknown default:
            break
        }
    }
}

指定した UUID の iBeacon しか検出できない

Android 版ではひとまず iBeacon 形式のものを受信してから UUID をチェックすることができましたが、iOS 版では、あらかじめ指定した UUID の iBeacon しか検出できません。確か 10 個程度しか登録できないので、複数の UUID を同時に使うときは、何らかの形で beaconRegion を切り替える必要があります。

func startScanning() {
    guard !isScanning else { return }
    
    // ビーコンリージョンを作成
    beaconRegion = CLBeaconRegion(
        uuid: defaultUUID,
        identifier: defaultIdentifier
    )
    beaconConstraint = CLBeaconIdentityConstraint(uuid: defaultUUID)
    
    guard let region = beaconRegion else { return }
    
    // リージョンモニタリング開始
    locationManager.startMonitoring(for: region)

    // すぐにレンジング開始(既にリージョン内にいる場合 didEnterRegion が来ないことがあるため)
    if let constraint = beaconConstraint {
        locationManager.startRangingBeacons(satisfying: constraint)
    }
    
    isScanning = true
    scanningStatus = "スキャン中..."
    print("iBeacon スキャン開始")
}

現在、固定の UUID しか受信できないので、アプリから複数 UUID を指定できるといいでしょう。通常は、UUID を固定にしておいて major と minor で識別することが多いです。

major と minor を ID として使う

CLLocationManagerDelegate#locationManager で受け取ったときに、CLBeacon の major と minor を取り出すことができます。FolkBears では、major と minor をワンセットにして TempUserID として使っています。

func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying beaconConstraint: CLBeaconIdentityConstraint) {
    let now = Date()
    let validBeacons = beacons.filter { $0.proximity != .unknown }

    DispatchQueue.main.async {
        self.discoveredBeacons = validBeacons
        self.scanningStatus = "検出: \(validBeacons.count)個"
    }

    for beacon in validBeacons {
        let majorHex = String(format: "%04X", beacon.major.uint16Value)
        let minorHex = String(format: "%04X", beacon.minor.uint16Value)
        print("ビーコン検出 - UUID: \(beacon.uuid), Major: 0x\(majorHex), Minor: 0x\(minorHex), RSSI: \(beacon.rssi), Distance: \(String(format: "%.2f", beacon.accuracy))m")
        DispatchQueue.main.async {
            self.onIBeacon?(beacon, now)
        }
    }
}

EN API 形式を受信する

COCOA で使っていた EN API 形式の受信機も iOS 版を作っていきます。つまりは、16 bit Service UUID を指定して受信するパターンです。CBCentralManager を使います。
これ、ずっと勘違いしていたのですが、iOS で 16 bit Service UUID は受信できますね。現在 EN API の 0xFD6F は塞がれたままなのですが、別の 16 bit Service UUID を送ると iOS で受信ができます。他の UUID とぶつからないように実験的に 0xFF00 を使うと受信できることが確認できます。

ちなみに iOS は 16 bit Service UUID で発信ができません。接触確認アプリの場合は受発信が必要なのでこのパターンは使えないのですが、何らかのデバイスで発信(m5stack など)したものを、iOS で受信することは十分可能です。なので、入場確認とかにこの方式が使えます。勿論、Bluetooth SIG で 16 bit Service UUID が必須になりますが…まあ、実験的にということで。

final class ENSimScan: NSObject, ObservableObject {
    
    /// 受信時のコールバック(UI側で集計する想定)
    var onReadTraceData: ((TraceData) -> Void)?

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

    private var centralManager: CBCentralManager!

    // ENSim (Exposure Notification Simulator) サービス UUID
    private let serviceUUID = CBUUID(string: "0000FD6F-0000-1000-8000-00805F9B34FB")
    private let serviceDataUUID = CBUUID(string: "0000FD6F-0000-1000-8000-00805F9B34FB")
    private let serviceUUIDalt = CBUUID(string: "0000FF00-0000-1000-8000-00805F9B34FB")
    private let serviceDataUUIDalt = CBUUID(string: "00000001-0000-1000-8000-00805F9B34FB")

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

    func startScan() {
        guard centralManager.state == .poweredOn else {
            print("ENSimScan: Bluetooth未準備のため開始できません state=\(centralManager.state.rawValue)")
            return
        }
        guard !isScanning else { return }

        centralManager.scanForPeripherals(
            // withServices: [serviceUUID, serviceUUIDalt],
            // FD6F を入れるとガードが掛かるので、外す
            withServices: [serviceUUIDalt],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
        )

        isScanning = true
        scanningStatus = "スキャン中..."
        print("ENSimScan: スキャン開始")
    }

    func stopScan() {
        guard isScanning else { return }
        centralManager.stopScan()
        isScanning = false
        scanningStatus = "停止中"
        print("ENSimScan: スキャン停止")
    }

    private func handleScanResult(peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) {
        // サービスデータから tempId を取得(FD6F優先、FF00や派生UUIDも許容)
        guard let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] else { return }

        let data = serviceData[serviceDataUUID]
            ?? serviceData[serviceUUID]          // 一部デバイスはサービスUUIDでそのまま入る場合がある
            ?? serviceData[serviceUUIDalt]       // 代替サービスUUID
            ?? serviceData[serviceDataUUIDalt]   // 代替サービスデータUUID

        guard let payload = data, !payload.isEmpty else { return }

        let tempId = payload.map { String(format: "%02X", $0) }.joined()
        let trace = TraceData(
            timestamp: Date(),
            tempId: tempId,
            rssi: rssi.doubleValue,
            txPower: (advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.doubleValue
        )

        print("ENSim 検出: \(peripheral.identifier.uuidString) tempId: \(tempId) rssi: \(rssi)")
        onReadTraceData?(trace)
    }
}

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

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        handleScanResult(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI)
    }
}

ここでは FD6F と FF00 の両方を受信するようにしたいところですが、scanForPeripherals で FD6F を指定すると FF00 のほうもガードが掛かって排除されてしまいます(苦笑)。なので、FF00 のほうだけ指定します。このガードの仕方はどうかと思うのですが、まあ、いいでしょう。

CBCentralManager のほうも BeaconRegion と同様に、フィルターする UUID の指定が必要になります。つまりは、受信するときのホワイトリストが必要になるわけです。どの程度の BLE デバイスのレベルでフィルターがかかっているかわかりませんが、アプリへのイベントは Android のようにすべてのイベントが飛んでくるわけではありません。

ちょっと長くなったので GATT 形式と Manufacturer Data 形式の受信機の解説は次回にします。

参考先

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

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

BLE 開発のための便利ツール群を公開しました

FolkBearsGroup/ble-tools: BLE 開発のための便利ツール群
https://github.com/FolkBearsGroup/ble-tools

ほどよく整理ができてきたので…というか、ブログに記事をまとめていると実験コードが散乱してしまうので FolkBearsGroup/ble-tools というリポジトリにまとめてアップしました。それぞれのツールは特に関係はしていないのですが、FolkBears を開発する際に使ったものです。

BLE の受発信は無線なのでよく見えないところが多いです。更にOSやプログラム言語により設定が異なるため、実行時に挙動が異なります。このあたり、たちたびツールを作って確認はしていたのですが、あまりまとめていなかったのでこの機会にまとめています。

scan_cocoa_python と scan_cocoa_win

解説は COCOA 受信(EN API 仕様)の実際 Windows + C# 版 | Moonmile Solutions Blog https://www.moonmile.net/blog/archives/12026 です。この記事では Windows 上で動作する C# になっていますが、scan_cocoa_python では Python で受信しています。

たぶん、node.js でも同じことができるはずなのですが、私のところではうまくいかなかったので up していません。

  • python では  bleak というライブラリを使っています
  • Windows 版は net10.0-windows10.0.22621.0 でいけます

scan_ibeacon_win

iBeacon の受信機を windows + C# で作っています。iBeacon の解説は BLE アドバタイズのパケット/iBeacon の詳細 | Moonmile Solutions Blog https://www.moonmile.net/blog/archives/11981 で書いたのですが、コードの解説はまだ書いてないみたい。

これも python とか node.js でも作れるはずです。
受信タイミングは Windows と Linux では異なるので、実行環境で確認しておくのは重要です。

transmitter_cocoa_m5stack

発信系は M5Stack で作っています。M5Stack は ESP32 を搭載しているので、BLE の発信が細かく制御できます。当然、Android や iOS で発信は可能ではあるのですが、いくつかの制限があるので、純粋に BLE の発信機を使うときは M5Stack のようなデバイスを使う方がいいです。

解説は COCOA 発信機(EN API仕様)を M5Stick Plus で作る | Moonmile Solutions Blog https://www.moonmile.net/blog/archives/12033 で書いています。

COCOA/EN API 仕様は 16 bit Service UUID で送信するパターンです。16 bit Service UUID は 0xFD6F を使います。
これも OS や機種によって、発信タイミングが異なるので Android や iOS で発信する部分もチェックしないといけません。

transmitter_manufacturer_m5stack

同じく M5Stack で Manufacturer Data で発信するパターンも作っています。
本当は Manufacturer Data が一番使い勝手がいいのでですが、iOS から Manufacturer Data を発信することができません。このために FolkBears では iBeacon 形式を使っているのですが、これもバックグラウンド動作等に難点があります。

このあたりの解説はまだ書いていないので、次の Android/iOS の受発信ツールも含めて書いていくつもりです。

folkbears-monitor-droid

Android で、iBeacon/EN API/FolkBears/manufacturer data の4種類のパターンで受信チェックをするツールです。FolkBears はもともと GATT サービスで接続する方式になっています。現在は iBeacon 形式と GATT サービスの両方で試すことができます。
この GATT サービスのときに相手のデバイスを探すときに、遅延しているらしいので、このツールを作っています。

解説は Android で iBeacon と EN API 受信機を作る | Moonmile Solutions Blog https://www.moonmile.net/blog/archives/12046 で書いています。

遅延のタイミングは、発信間隔と受信間隔の微妙なところがあります。その実験のためにつかいます。発信側は発信タイミング(Scan Window/Scan Interval)を細かく設定できる m5stack を使っていますが、受信側は Android も iOS も Scan Window/Scan Interval を のツールを使うとよいです。
また、実機測定として相手側を Android/iOS にして確認していきます。

folkbears-monitor-ios

Android と同じパターンで iOS 版も作っています。
Android と同じように4種類の BLE 受信のパターンをチェックできます。iOS の場合でも manufacturer data の data 部分が取得できています(発信はできない)。

解説のほうも書いていきます。

これからの予定

Android と iOS の発信機がないので、ざっと作っていきます。
あと、m5stack での発信ツールの不足分(iBeacon版)も足していきます。
ほかにも、設定関係やログ出力機能がないので、これも追加していきたいですね。あくまで開発ツールの位置づけなので、本格的なものは本体の FolkBears に入れていく予定です。

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

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

前回 m5stack で発信機ができたので、今度は Android で受信機を作ってみましょう。実際に COCOA などでもアプリとしてスマホが使われたので、スマホで BLE を使ったときの精度を実測してみるのは重要です。

Jetpack Compose で作る

2020年当時は Android 開発は XML レイアウトが主流でしたが、最近は Jetpack Compose で作るほうが断然楽です。

特に、リストを表示するときに不可思議な Adapter を作らなくて済みます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            FolkBearsMonitorTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MonitorScreen(
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun MonitorScreen(modifier: Modifier = Modifier) {
    var selectedTab by rememberSaveable { mutableStateOf(MonitorTab.IBeacon) }

    Column(modifier = modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTab.ordinal) {
            MonitorTab.entries.forEach { tab ->
                Tab(
                    selected = tab == selectedTab,
                    onClick = { selectedTab = tab },
                    text = { Text(tab.title) }
                )
            }
        }

        when (selectedTab) {
            MonitorTab.IBeacon -> IBeaconTabContent()
            MonitorTab.FolkBears -> FolkBearsTabContent()
            MonitorTab.EnApi -> EnApiTabContent()
            MonitorTab.Others -> PlaceholderTab(text = selectedTab.contentLabel)
        }
    }
}

@Composable
private fun PlaceholderTab(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.bodyLarge,
        modifier = Modifier
            .padding(16.dp)
            .fillMaxSize()
    )
}

@Composable
private fun IBeaconTabContent() {
    val context = LocalContext.current
    val scan = remember { BeaconScan(context) }
    val ads: SnapshotStateList<IBeaconAdvertisement> = remember { mutableStateListOf() }
    val windowMs = 5 * 60 * 1000L // 5 minutes

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

    // Collect scan results
    LaunchedEffect(scan) {
        if (hasPermission) {
            scan.onIBeacon = { ad ->
                ads.add(ad)
                pruneOld(ads, windowMs)
            }
            scan.startScan()
        }
    }

    // Periodic prune to keep window sliding
    LaunchedEffect(ads) {
        while (true) {
            pruneOld(ads, windowMs)
            delay(10_000)
        }
    }

    // Stop scan when composable leaves the composition
    DisposableEffect(hasPermission) {
        onDispose { scan.stopScan() }
    }

    if (!hasPermission) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp)
        ) {
            Text(
                text = "Bluetoothスキャン権限が必要です。許可してください。",
                style = MaterialTheme.typography.bodyLarge
            )
            Button(
                onClick = {
                    permissionLauncher.launch(
                        arrayOf(
                            android.Manifest.permission.BLUETOOTH_SCAN,
                            android.Manifest.permission.BLUETOOTH_CONNECT,
                            android.Manifest.permission.ACCESS_FINE_LOCATION
                        )
                    )
                },
                modifier = Modifier.padding(top = 12.dp)
            ) {
                Text("権限をリクエスト")
            }
        }
        return
    }

    val grouped = ads.groupBy { Triple(it.serviceUuid, it.major, it.minor) }
        .map { (key, values) ->
            val last = values.maxByOrNull { it.timestamp }!!
            IBeaconRowData(
                serviceUuid = key.first,
                major = key.second,
                minor = key.third,
                count = values.size,
                lastSeen = last.timestamp,
                rssi = last.rssi,
                txPower = last.txPower
            )
        }
        .sortedByDescending { it.lastSeen }

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(grouped, key = { "${it.serviceUuid}-${it.major}-${it.minor}" }) { row ->
            IBeaconRow(row)
        }
    }
}

Flutter や React Native を使うことも考えられるのですが、BLE 周りの制御をネイティブで書く必要があるので、Kotlin で書くのがベターです。特に実験用のアプリでもあるので、ライブラリの制限を受けないようにしておきます。この方針は、FolkBears も同じです。

BLE スキャン

iBeacon をスキャンするときの BeaconScan クラスです。これは FolkBears で使っているものと同じです。FolkBears では traceDeviceRepository で 10 秒間程度同じデバイスを受信しないようにしているのですが、ここでは実験のため利用していません。素直に iBeacon を受信したときに、コールバックの onIBeacon を呼び出すようにしています。

class BeaconScan(
    private val context: Context
) {

    companion object {
        const val TAG = "BeaconScan"
        private const val REQUEST_PERMISSIONS_CODE = 1001
        // val SERVICE_UUID: UUID = App.SERVICE_UUID
        val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13")        // FolkBears サービス
    }
    private val traceDeviceRepository = TraceDeviceRepository()

    private var scanner: BluetoothLeScanner? = null
    private var scanCallback : ScanCallback? = null

    // Beacon スキャン結果を受け取るコールバック
    var onReadTraceData: (TraceDataEntity) -> Unit = {}
    var onIBeacon: (IBeaconAdvertisement) -> Unit = {}

    private fun setupBeaconMonitoring() {
        Log.d(TAG, "setupBeaconMonitoring")
        if (!hasScanPermission()) {
            Log.w(TAG, "BLE scan permission not granted; requesting")
            requestScanPermission()
            return
        }
        val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        val adapter = bluetoothManager.adapter
        scanner = adapter.bluetoothLeScanner
        if (scanner == null) {
            Log.e(TAG, "BluetoothLeScanner is not available")
            return
        }   
        val scanFilter = ScanFilter.Builder()
            .setManufacturerData(0x004C, byteArrayOf(0x02, 0x15)) // Apple iBeacon の識別データ
            // .setServiceUuid(ParcelUuid(SERVICE_UUID)) // フィルターが効かない
            .build()
        val scanSettings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
            .build()

        scanCallback = object : ScanCallback() {
            override fun onScanResult(callbackType: Int, result: ScanResult?) {
                result?.let { scanResult ->
                    val parsed = parseIBeacon(scanResult) ?: return
                    val deviceAddress = scanResult.device?.address

                    val tempid = "%04x".format(parsed.major) + "%04x".format(parsed.minor)
                    val timestamp = parsed.timestamp
                    val dataEntity = TraceDataEntity(
                        tempId = tempid,
                        timestamp = timestamp,
                        rssi = parsed.rssi,
                        txPower = parsed.txPower
                    )
                    // traceDeviceRepository を使わない
                    onIBeacon(parsed)
                    /*
                    // 10秒以前を削除する
                    traceDeviceRepository.setTimestamp(timestamp = timestamp)
                    // デバイスアドレスが登録されていない場合のみ、データを読み込む
                    if (!traceDeviceRepository.checkMacAddress(deviceAddress ?: "")) {
                        traceDeviceRepository.readTempId(
                            mac = deviceAddress ?: "",
                            tempId = tempid,
                            timestamp = timestamp
                        )
                        onIBeacon(parsed)
                        // コールバックの呼び出し
                        onReadTraceData(dataEntity)
                    }
                    */
                }
            }
            override fun onScanFailed(errorCode: Int) {
                Log.d(TAG, "onScanResult: error")
                super.onScanFailed(errorCode)
            }
        }
        Log.d(TAG, "iBeacon スキャン開始")
        scanner?.startScan(listOf(scanFilter), scanSettings, scanCallback)
    }

    private fun hasScanPermission(): Boolean {
        val scan = ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
        val legacy = ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED
        return scan || legacy 
    }

    private fun requestScanPermission() {
        val activity = context as? Activity ?: run {
            Log.w(TAG, "Context is not Activity; cannot show permission dialog")
            return
        }
        val needs = mutableListOf<String>()
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
            needs += Manifest.permission.BLUETOOTH_SCAN
        }
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
            needs += Manifest.permission.BLUETOOTH_CONNECT
        }
        // Fallback for pre-Android 12
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) {
            needs += Manifest.permission.BLUETOOTH
        }
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            needs += Manifest.permission.ACCESS_FINE_LOCATION
        }
        if (needs.isEmpty()) return

        ActivityCompat.requestPermissions(activity, needs.toTypedArray(), REQUEST_PERMISSIONS_CODE)
    }

    private fun parseIBeacon(result: ScanResult): IBeaconAdvertisement? {
        val record = result.scanRecord ?: return null
        val payload = record.getManufacturerSpecificData(0x004C) ?: return null
        // iBeacon payload size should be 23 bytes: 0x02 0x15 + UUID(16) + major(2) + minor(2) + tx(1)
        if (payload.size < 23) return null
        if (payload[0] != 0x02.toByte() || payload[1] != 0x15.toByte()) return null

        fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02X".format(eachByte) }

        val uuidBytes = payload.sliceArray(2 until 18)
        val serviceUuid = uuidBytes.toHex()
        val major = (payload[18].toInt() and 0xFF) * 256 + (payload[19].toInt() and 0xFF)
        val minor = (payload[20].toInt() and 0xFF) * 256 + (payload[21].toInt() and 0xFF)
        val txPower = payload[22].toInt()
        val rssi = result.rssi

        return IBeaconAdvertisement(
            serviceUuid = serviceUuid,
            major = major,
            minor = minor,
            timestamp = System.currentTimeMillis(),
            rssi = rssi,
            txPower = txPower
        )
    }

    ///
    /// @brief Beacon スキャンサービスを開始する
    ///
    fun startScan() {
        Log.d(TAG, "startScan")
        setupBeaconMonitoring()
    }
    ///
    /// @brief Beacon スキャンサービスを停止する
    ///
    fun stopScan() {
        Log.d(TAG, "stopScan")
        scanner?.stopScan(this.scanCallback)
        scanner = null
    }
}

EN API スキャン

同じパターンで EN API 型のスキャンコードを ENSimScan クラスとして作成します。EN API の 16 bit UUID 0xFD6F を使ってフィルタリングしてあります。実は、0xFD6F は EN API なので Android のほうでガードが掛かっている筈…なのですが、今は大丈夫そうですね。Android OS のバージョンによってはガードが掛かっている可能性があるので、別の 16 bit UUID に変えて実験するのが望ましいです。

class ENSimScan(
    private val context: Context
) {

    companion object {
        const val TAG = "ENSimScan"
        val SERVICE_UUID: UUID = UUID.fromString("0000FD6F-0000-1000-8000-00805F9B34FB")
    }

    private val traceDeviceRepository = TraceDeviceRepository()
    private var scanner: BluetoothLeScanner? = null
    private var scanCallback: ScanCallback? = null

    // ENSim スキャン結果を受け取るコールバック
    var onReadTraceData: (TraceDataEntity) -> Unit = {}

    private fun setupScan() {
        Log.d(TAG, "setupScan")
        val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        val adapter = bluetoothManager.adapter
        scanner = adapter.bluetoothLeScanner

        // 16 bit UUID
        val serviceUuid = ParcelUuid(SERVICE_UUID)

        val scanFilter = ScanFilter.Builder()
            .setServiceUuid(serviceUuid)
            .build()
        val scanSettings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
            .build()

        scanCallback = object : ScanCallback() {
            override fun onScanResult(callbackType: Int, result: ScanResult?) {
                fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02X".format(eachByte) }

                result?.let {
                    val serviceData = it.scanRecord?.getServiceData(serviceUuid)
                    if (serviceData != null && serviceData.isNotEmpty()) {
                        val tempId = serviceData.toHex()
                        val deviceAddress = result.device?.address ?: ""
                        val timestamp = System.currentTimeMillis()
                        val rssi = result.rssi
                        val txPower = result.txPower

                        Log.d(TAG, "ENSim 検出: $deviceAddress tempId:$tempId rssi:$rssi tx:$txPower")

                        val dataEntity = TraceDataEntity(
                            tempId = tempId,
                            timestamp = timestamp,
                            rssi = rssi,
                            txPower = txPower
                        )
                        // traceDeviceRepository を使わない
                        onReadTraceData(dataEntity)
                        /*

                        traceDeviceRepository.setTimestamp(timestamp = timestamp)

                        // デバイスアドレスが未登録の場合のみ、新規として追加し、コールバックを通知
                        if (!traceDeviceRepository.checkMacAddress(deviceAddress)) {
                            traceDeviceRepository.readTempId(
                                mac = deviceAddress,
                                tempId = tempId,
                                timestamp = timestamp
                            )
                            onReadTraceData(dataEntity)
                        }
                        */
                    }
                }
            }

            override fun onScanFailed(errorCode: Int) {
                Log.d(TAG, "onScanResult: error $errorCode")
                super.onScanFailed(errorCode)
            }
        }

        Log.d(TAG, "ENSim スキャン開始")
        scanner?.startScan(listOf(scanFilter), scanSettings, scanCallback)
    }

    ///
    /// ENSim スキャンサービスを開始する
    ///
    fun startScan() {
        Log.d(TAG, "startScan")
        setupScan()
    }

    ///
    /// ENSim スキャンサービスを停止する
    ///
    fun stopScan() {
        Log.d(TAG, "stopScan")
        scanner?.stopScan(this.scanCallback)
        scanner = null
    }
}

GATT コネクションのスキャン

FolkBears のコネクション版では GATT で接続してから TempID を読み出す方式になっています。このとき、接続先のデバイスを見つけるためにスキャンをする必要があるのですが、これだけを試しています。実質的に iBeacon や EN API をスキャンをするときと同じになります。

このあたり

1. 接続先のデバイスを探索
2. 見つかったデバイスに接続
3. TempID を読み出す
4. 切断する

と言うシーケンスのうちの 1 の段階だけです。FolkBears を改修しているときにこのコネクション型の接続が非常に悪くて色々調べていたのです。原因は 2 か 3 あたりにあると考えていたのですが、どうやら 1 が主原因のようです。これは実験で確認します。

class GattClient(
    private val context: Context
)  {

    companion object {
        const val TAG = "GattClient"
        // val SERVICE_UUID: UUID = App.SERVICE_UUID
        // val CHARACTERISTIC_UUID: UUID = App.CHARACTERISTIC_UUID
        val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13")
        val CHARACTERISTIC_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA14")

    }

    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothScanner: BluetoothLeScanner? = null
    private var scanCallback: ScanCallback? = null
    private val traceDeviceRepository = TraceDeviceRepository()

    // スキャン結果を受け取るコールバック
    var onScanGattDevice: (String, ScanResult) -> Unit = { _, _ -> }
    var onReadTraceData: (TraceDataEntity) -> Unit = {}

    init {
        val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothAdapter = bluetoothManager.adapter
        bluetoothScanner = bluetoothAdapter?.bluetoothLeScanner
    }

    ///
    /// @brief GATT クライアントサービスを開始する
    ///
    fun startSearchDevice() 
    {
        Log.d(TAG, "startSearchDevice")
        val scanFilter = ScanFilter.Builder()
            // TODO: SERVICE_UUID を提供しているデバイスを探す
            .setServiceUuid(ParcelUuid(SERVICE_UUID))
            .build()

        val scanSettings = ScanSettings.Builder()
            .setReportDelay(0)
            .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
            .build()

        // デバイス名と時刻を保存するリスト
        this.scanCallback = object : ScanCallback() {
            override fun onScanResult(callbackType: Int, result: ScanResult) {
                if ( result.scanRecord == null ) return
                // MAC アドレスを取得
                val deviceAddress = result.device.address
                Log.d(TAG, "デバイス 検出: $deviceAddress")
                onScanGattDevice( deviceAddress, result )

                // traceDeviceRepository を使わない
                /*
                // 10秒以前を削除する
                traceDeviceRepository.setTimestamp(
                    timestamp = System.currentTimeMillis()
                )
                if (!traceDeviceRepository.checkMacAddress( deviceAddress )) {
                    // デバイスがリストに存在しない場合は接続
                    traceDeviceRepository.connectDevice(
                        deviceAddress, System.currentTimeMillis(), result.rssi)
                    connectToGattServer(deviceAddress)
                }
                */
            }
        }
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.BLUETOOTH
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        Log.d(TAG, "startSearchDevice startScan")
        bluetoothScanner?.startScan(listOf(scanFilter), scanSettings, scanCallback)
    }

    ///
    /// @brief GATT クライアントサービスを停止する
    ///
    fun stopSearchDevice() {
        Log.d(TAG, "stopSearchDevice")
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.BLUETOOTH
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        bluetoothScanner?.stopScan(this.scanCallback)
    }

    private fun connectToGattServer( deviceAddress: String ) {
        Log.d(TAG, "connectToGattServer: $deviceAddress")
        /*
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.BLUETOOTH_CONNECT
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            Log.w(TAG, "Bluetooth Connect permission not granted")
            return
        }
        */
        val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
        device?.connectGatt(context, false, gattCallback)
    }

    @SuppressLint("MissingPermission")
    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (status != BluetoothGatt.GATT_SUCCESS) {
                Log.w(TAG, "GATT_ERROR($status)発生")
                gatt.close()
            } else if (newState == BluetoothProfile.STATE_CONNECTED) {
                gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)
                gatt.requestMtu(185) // iOS との互換性のために MTU サイズを 185 に設定
                Log.d(TAG, "接続成功")
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                Log.d(TAG, "接続切断")
                gatt.close()
            }
        }
        override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
            Log.d(TAG, "onMtuChanged")
            gatt?.let {
                gatt.discoverServices() // サービスを探索
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            Log.d(TAG, "onServicesDiscovered")
            if (status == BluetoothGatt.GATT_SUCCESS) {
                val service = gatt.getService(SERVICE_UUID)
                val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID)
                if (characteristic != null) {
                    gatt.readCharacteristic(characteristic)
                }
            }
        }


        private fun onCharacteristicReadInner( gatt: BluetoothGatt, s: String ) {
            try {
                val device = traceDeviceRepository.findDevice( gatt.device.address )
                val rssi = device!!.rssi

                val json = JSONObject(s)
                val tempid = json.getString("i")
                Log.d(TAG, "TempID: $tempid")
                val timestamp = System.currentTimeMillis()
                val dataEntity = TraceDataEntity(
                    tempId = tempid,
                    timestamp = timestamp,
                    rssi = rssi,
                    txPower = 0
                )
                traceDeviceRepository.readTempId(
                    mac = gatt.device.address,
                    tempId = tempid,
                    timestamp = timestamp
                )                    
                // コールバックの呼び出し
                onReadTraceData(dataEntity)
            } catch (e: Exception) {
                Log.e(TAG, "JSON parse error: $e")
            }
            // 切断する
            gatt.disconnect()
        }

        override fun onCharacteristicRead(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            value: ByteArray,
            status: Int
        ) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                val size = value.size
                val s = String(value, Charsets.UTF_8)
                Log.d(TAG, "size: $size TempID: $s")
                onCharacteristicReadInner(gatt, s)
            }
        }

        @Deprecated("Deprecated in API 33")
        override fun onCharacteristicRead(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            status: Int
        ) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                val dataBytes = characteristic.value
                val size = characteristic.value.size
                val s = String(dataBytes, Charsets.UTF_8)
                Log.d(TAG, "size: $size TempID: $s")
                onCharacteristicReadInner(gatt, s)
            }
        }
    }
}

実行結果

受信頻度つまり Scan Window/Scan Interval の設定は、Android の場合は ScanSettings 列挙を使います。

  • SCAN_MODE_LOW_LATENCY: 高頻度スキャン
  • SCAN_MODE_LOW_POWER: 低頻度スキャン
  • SCAN_MODE_BALANCED: バランス型
  • SCAN_MODE_OPPORTUNISTIC: OS に任せる

一般的なアプリでは精度よく受信ができるために SCAN_MODE_LOW_LATENCY を使うことが多いのですが、COCOA のように常時動かしている場合にはバッテリーの関係もあって SCAN_MODE_LOW_POWER を使うことになります。あるいは SCAN_MODE_BALANCED でもいいかもしれません。

で、このパターンで実測をすると、受信頻度/間隔はどれくらいになるだろうか?ということを実験します。
ただし、正確な実験は、以下のような組み合わせがあるので非常に面倒くさいです。

  • 発信機の送信間隔
  • 受信機のスキャン間隔
  • OS による違い(Android, iOS, Windows, m5stack など)

今回は発信側が iOS と Android, 受信側が Android の組み合わせで実験します。
発信機のほうは FolkBears のコネクションモード(GATTモード)、iBeacon モードを利用します。

SCAN_MODE_LOW_LATENCY の場合

頻度を高くして受信

上が iOS からの発信で、下が Android からの発信です。
Android のほうは発信頻度を低くしてあるので、差が出ているのは仕方がないのですが、iOS と Android の受信の差は 20 倍以上違います。

この現象は、1年ほど前から気になっていて、どういう組み合わせになるのか色々調べていて、やっとここの Duty Cycle の違いに原因があることに気づきました。これは先行き探っていきます。

SCAN_MODE_LOW_POWER の場合

さらに問題になるのは、バッテリーを節約しようとして SCAN_MODE_LOW_POWER を使った場合です。

SCAN_MODE_LOW_POWER の場合は、ほぼ 1 秒間隔でスキャンをしていきます。

受信頻度が落ちているのですが、iOS 発信の場合は 1 秒ごとに 3 回位、Android の場合は、1,2 秒で 1 回位のペースで iBeacon を受信できます。

ですが、FolkBears の場合は、iOS 発信の場合は iBeacon 型と変わらないのですが、Android 発信の場合はかなり減ってしまいます。10 秒に 1 回ぐらいか、更に条件が悪いときは 60 秒ぐらいに 1 回位になることがあります。このあたりは正確に実測しないといけません。

このあたりは、発信頻度のほうも固定にしないといけないので、m5stack などを使ってもうちょっと正確に実測できる環境を作ります。

この時点では、

  • iOS の iBeacon は高頻度で発信している
  • Android の SCAN_MODE_LOW_POWER は、かなり受信頻度が落ちる

ことが体感できれば十分です。

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

COCOA 発信機(EN API仕様)を M5Stick Plus で作る

前回、COCOA の EN API 仕様のアドバタイズを Windows + C# で受信する方法を書きました。今回は M5Stick Plus で COCOA 発信機を作る方法です。

EN API のデータフォーマット

  • Flags: 1A
  • Complete 16-bit Service UUIDs: FD6F
  • Service Data:
    – 16-bit Service UUID: FD6F
    – RPI (Rolling Proximity Identifier): 16 bytes
    – AEM (Associated Encrypted Metadata): 4 bytes

の形でデータをセットしていきます。

M5Stick CPlus での実装例

基本的に set_advertising_data 関数でアドバタイズデータをセットして、advertising->start() で発信し続けます。loop 関数は特に必要ないのですが、後でログなどを入れる予定です。

RPI と AEM はダミーデータです。本格的に EN API 仕様に合わせるならば暗号化されたデータを入れることになりますが、今回は送受信実験のためなので確認しやすい値をいれておきます。

#include <M5StickCPlus.h>   // m5stack/M5StickCPlus
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLEAdvertising.h>

// BLE関連
BLEAdvertising *advertising;
// アドバタイズデータ作成
void set_advertising_data();

/**
 * @brief BLE初期化
 */
void ble_init() {
  // BLEの初期化
  BLEDevice::init("cocoa");
  set_advertising_data();
}

/**
 * @brief アドバタイズ送信設定
 * 
 */

// Rolling Proximity Identifier
char rpi[16] { 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 
               0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04 };  
// Asscociated Encrypted Metadata
char aem[4]  { 0x05, 0x05, 0x05, 0x05, };  

void set_advertising_data()
{
    // アドバタイズ送信パワー変更(-3dBm ~ 10dBm:デフォルト0dBm)
    esp_power_level_t dbm = ESP_PWR_LVL_N0;
    
    (ESP_BLE_PWR_TYPE_ADV, dbm);
      // アドバタイズデータ作成
    BLEAdvertisementData advertisementData;
    // Flags
    advertisementData.setFlags(0x1A);
    // Complete 16-bit Service UUID
    advertisementData.setCompleteServices(BLEUUID("FD6F"));
    // Service Data(Service Data 16-bit Service UUID)
    std::string strServiceData = "";
    // Append RPI and AEM
    for (int i = 0; i < sizeof(rpi); i++) {
        strServiceData += rpi[i];
    }
    for (int i = 0; i < sizeof(aem); i++) {
        strServiceData += aem[i];
    } 
    // Service Data 16-bit Service UUID
    advertisementData.setServiceData(BLEUUID("FD6F"), strServiceData);

    // アドバタイズの設定
    advertising = BLEDevice::getAdvertising();
    // advertising->setAdvertisementType(ADV_TYPE_NONCONN_IND); // コネクション不要
    // advertising->setScanResponse(false);
    advertising->setAdvertisementData(advertisementData);
    // アドバタイズ間隔を 100 ms に設定
    advertising->setMinInterval(0x00A0); // 約100 ms
    advertising->setMaxInterval(0x00A0); // 約100 ms
}

/**
 * @brief 初期化
 *
 */
void setup() {
  M5.begin();

  // 動作周波数を80MHzにする(BLEが使用できる最低の周波数)
  // setCpuFrequencyMhz(80);
  // デバッグ用
  Serial.begin(115200);
  Serial.println("M5StickC Plus BLE Transmitter Start");
  // BLE初期化
  ble_init();
  // アドバタイズ開始
  advertising->start();
}

/**
 * @brief メインループ
 *
 */
int count = 0;
void loop() {
  M5.update();
  // 画面表示
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.printf("COCOA Transmitter\n");
  M5.Lcd.printf("Count: %d\n", count++);
  sleep(1);
}

1. esp_ble_tx_power_set 関数でアドバタイズの送信パワーを設定
2. BLEAdvertisementData クラスでアドバタイズデータを作成
3. setFlags 関数で Flags を 0x1A に設定
4. setCompleteServices 関数で Complete 16-bit Service UUIDs を FD6F に設定
5. setServiceData 関数で Service Data を設定(16-bit Service UUID + RPI + AEM)
6. BLEAdvertising クラスでアドバタイズの設定を行い、start 関数でアドバタイズを開始

  • advertising->setAdvertisementType で ADV_TYPE_NONCONN_IND を指定して、コネクション不要に設定
  • advertising->setScanResponse でスキャンレスポンスを無効に設定
  • setMinInterval と setMaxInterval でアドバタイズ間隔を設定(100ms)

setAdvertisementType と setScanResponse はデフォルトのままで大丈夫なはずです。この値は、Flags に設定されます。advertisementData.setFlags(0x1A) で設定済みです。

Core Specification Supplement, PartA, Section 1.3

  • 0x01: LE Limited Discoverable Mode
  • 0x02: LE General Discoverable Mode
  • 0x04: BR/EDR Not Supported
  • 0x08: Simultaneous LE and BR/EDR to Same Device Capable (Controller)
  • 0x10: Previously Used

0x1A は 00011010 なので、LE General Discoverable Mode と BR/EDR Not Supported が立っています。

M5Stick Plus で動作確認

ハングアップしてないことを確認するために 1 秒ごとに画面が変わります。

受信確認

前回作成した Windows + C# 版の受信プログラムで、M5StickC Plus から発信された COCOA フォーマットのアドバタイズを受信できます。

もうひとつ、発信間隔を 1 秒程度に替えたものを実験してみます。

    // アドバタイズ間隔を 1000 ms に設定
    advertising->setMinInterval(0x00A0*10); // 約1000 ms
    advertising->setMaxInterval(0x00A0*10); // 約1000 ms

windows のツールですが、受信間隔が

  • 100 msec 発信のときは、約 1000 msec ごとに受信
  • 1000 msec 発信のときは、約 2000 msec ごとに受信

しています。先の duty cycle の関係があるので、発信間隔と受信間隔は比例しないわけです。なので、発信間隔を間延びさせて受信側の Scan Window を広げるとか、逆に Scan Window を狭めて発信間隔を短くするとか、そういう調節ができます。

逆に言えば、ここの事実を考慮しないと EN API 仕様の受信はうまくいない可能性が高いということですね。windows の場合は duty cylce を調節できないので、どれだけの Scan Window になっているかは不明ですが、Android の場合はある程度設定ができます。iOS の場合は、duty cycle が自動調節になっているので、これも不明なところが多いです。

以前、Android で実験をしたときは、Android の場合は受信頻度が機種によって非常に悪いときがあります。となりに置いてある Windows マシンの受信間隔よりも Android のほうが間延びして受信しています。最悪の場合は 30 秒程度おくれることもあります。
このあたりは、再び Android での受信機と、m5stack/ESP32 での受信機を作って実験してみる必要がありそうです。

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