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 | iPhone(iOS)で iBeacon と EN API 受信機を作る はコメントを受け付けていません

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 | BLE 開発のための便利ツール群を公開しました はコメントを受け付けていません

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 | Android で iBeacon と EN API 受信機を作る はコメントを受け付けていません

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 | COCOA 発信機(EN API仕様)を M5Stick Plus で作る はコメントを受け付けていません

COCOA 受信(EN API 仕様)の実際 Windows + C# 版

既に COCOA も廃止になっていて、実測することはできないのですが、EN API のアドバタイズ受信機も Windows + C# で作成できます。当時は Android を使って受信させていたのですが、iBeacon の受信機と同じように Windows で作ることができます。

電文フォーマットがわかっているので、受信データを解析すればよいだけです。iBeacon とは違って、16 bit のサービス UUID(0xFD6F)を持っているので、これを選別してデータを受信します。

using System;
using System.Collections.Generic;
using System.Linq;
using Windows.Devices.Bluetooth.Advertisement;
using Windows.Storage.Streams;

// BLEのスキャナ
BluetoothLEAdvertisementWatcher watcher;

Main(args);

void Main(string[] args)
{
    Console.WriteLine("COCOA Check");
    watcher = new BluetoothLEAdvertisementWatcher()
    {
        ScanningMode = BluetoothLEScanningMode.Passive
    };
    // スキャンしたときのコールバックを設定
    watcher.Received += Watcher_Received;
    // スキャン開始
    watcher.Start();
    // キーが押されるまで待つ
    Console.WriteLine("Press any key to continue");
    Console.ReadLine();
}

void Watcher_Received(
    BluetoothLEAdvertisementWatcher sender,
    BluetoothLEAdvertisementReceivedEventArgs args)
{

    var uuids = args.Advertisement.ServiceUuids;
    var mac = string.Join(":",
                BitConverter.GetBytes(args.BluetoothAddress).Reverse()
                .Select(b => b.ToString("X2"))).Substring(6);
    var rssi = args.RawSignalStrengthInDBm;
    var time = args.Timestamp.ToString("yyyy/MM/dd HH:mm:ss.fff");
    
    if (uuids.Count == 0) return;
    // 0xFD6F は Exposure Notification のサービスUUID
    if (uuids.FirstOrDefault(t => t.ToString() == "0000fd6f-0000-1000-8000-00805f9b34fb") == Guid.Empty) return;

    foreach (var it in args.Advertisement.DataSections)
    {
        if ( it.DataType == 0x16 && it.Data.Length >= 2 + 16)
        {
            byte[] data = new byte[it.Data.Length];
            DataReader.FromBuffer(it.Data).ReadBytes(data);
            if ( data[0] == 0x6f && data[1] == 0xfd)
            {
                byte[] rpi = data[2..18];
                Console.WriteLine($"{time} [{tohex(rpi)}] {rssi} dBm {mac}");
            }
        }
    }

    string tohex( byte[] data )
    {
        return BitConverter.ToString(data).Replace("-", "").ToLower();
    }
}

1. args.Advertisement.ServiceUuids で Service UUID 0xFD6F を確認します。
2. args.Advertisement.DataSections で AD Type 0x16(Service Data – 16-bit UUID)を探します。
3. DataSections(Service Data) から Service Data UUID 0xFD6F を探し出します。
4. 見つかった Service Data から RPI 情報を取り出します

BluetoothLEAdvertisementWatcher で受信するときに、args.Advertisement.ServiceUuids とか args.Advertisement.DataSections のように複数の Service UUID が取れるような感じになっていますが、BLE アドバタイズは 32 bytes までしかないので、実質 1 つしか送れません。何故こうなっているのかわからないのですが、拡張のためでしょうか? 確か、GATT の場合は Service UUID をペリフェラルに問い合わせるモードがあるのでそのためかもしれません。

ひとまず、Service UUID である 0xFD6F を 2 回探していますが、そういう電文フォーマットになっているからです。Service Data UUID のほうの 0xFD6F は自由に決められるので、この値じゃなくても良いはずなのですが、COCOA ではこうなっています。

この 16 bit Service UUID を使ったフォーマットは、Bluetooth SIG で 16 bit Company ID を持った会社であれば自由に作れるので、iBeacon のように色々な用途で使えます。温度センサーをアドバタイズで発信するとか、位置情報を発信するとか、そういう用途です。GATT コネクションの Notify よりも手軽ので、受信側を問わなければ結構使い道があると思うのですが、いまのところあまり使われていないようです。と云うか、受信機をアプリ等で作らないといけないので、何か専門用途に使っていてあまり外部流出しないのでしょう。EN API の場合のように一般的なアプリが受信するというパターンは珍しいのだと思います。

受信データを全部見る場合

Android の場合は ScanCallback を使って受信した生データを取得することができるのですが、BluetoothLEAdvertisementWatcher クラスでは生データという形でみることはできません。ただし、Advertisement.DataSections を使って中身をみることができます…というのは既に書いてあるのですが、すべての AD セクションを表示するコードは以下のようになります。

    // できるだけ raw data を全部表示する場合
    foreach (var section in args.Advertisement.DataSections)
    {
        byte[] buf = new byte[section.Data.Length];
        DataReader.FromBuffer(section.Data).ReadBytes(buf);
        Console.WriteLine($"AD 0x{section.DataType:X2} len={buf.Length} data={BitConverter.ToString(buf)}");
    }
AD 0x01 len=1 data=1A
AD 0x03 len=2 data=6F-FD
AD 0x16 len=22 data=6F-FD-01-01-01-01-02-02-02-02-03-03-03-03-04-04-04-04-05-05-05-05
  • AD Type: 1byte
  • Length: 1byte
  • Data: n bytes

という形ででているのが良く分かります。

実測状態

ちょっと、m5stack で COCOA 発信機を作らないといけないので、その後で。
受信した状態はこんな感じになります。

RPI 情報は適当に埋めてあるので、そのデータが流れています。COCOA が動作しているとすると、こんな感じで RPI 情報が取得できるわけです。

カテゴリー: 開発, FolkBears | COCOA 受信(EN API 仕様)の実際 Windows + C# 版 はコメントを受け付けていません

iBeacon 受信の実際 Windows + C# 版

iBeacon のような BLE アドバタイズや、 GATT サービスが色々飛んでいます、というのをチェックするためのツールは BLE 通信チェックツールのあれこれ https://www.moonmile.net/blog/archives/11961 に書いたわけですが、これだとスマホにアプリを入れてあちこち探すという程度のことしかできません。逆に言えば、実験的に iBeacon の UUID は決めてあるので、それだけ受信できれば良いのです。

これは iBeacon や EN API(COCOA のアドバタイズ)の発信チェックに使えます。何かスマホや M5Stack で作成 iBeacon の発信ツールを作ったときに、本当に発信できているのか、あるいは、発信していないのか(電力消費を抑えるために定期的に停止する場合もあります)を確認するのに使います。

iBeacon の電文フォーマットはわかっているので、これに沿って実装します。

Windows の場合は、BLE を使うのに UWP アプリケーションのライブラリを使います。以前は色々面倒だったのですが、TargetFramework に ‘net10.0-windows10.0.22621.0’ のように指定すれば大丈夫です。これで BLE を受信するための BluetoothLEAdvertisementWatcher クラスが使えるようになります。

using System;
using System.Collections.Generic;
using System.Linq;
using Windows.Devices.Bluetooth.Advertisement;
using Windows.Storage.Streams;

// BLEのスキャナ
BluetoothLEAdvertisementWatcher watcher;

Main(args);

void Main(string[] args)
{
    Console.WriteLine("Folkbears iBeaconCheck");

    watcher = new BluetoothLEAdvertisementWatcher()
    {
        ScanningMode = BluetoothLEScanningMode.Passive
    };
    // スキャンしたときのコールバックを設定
    watcher.Received += Watcher_Received;
    // スキャン開始
    watcher.Start();
    // キーが押されるまで待つ
    Console.WriteLine("Press any key to continue");
    Console.ReadLine();
}

void Watcher_Received(
    BluetoothLEAdvertisementWatcher sender,
    BluetoothLEAdvertisementReceivedEventArgs args)
{
    var mac = string.Join(":",
                BitConverter.GetBytes(args.BluetoothAddress).Reverse()
                .Select(b => b.ToString("X2"))).Substring(6);
    var rssi = args.RawSignalStrengthInDBm;
    var time = args.Timestamp.ToString("yyyy/MM/dd HH:mm:ss.fff");
    

    if ( args.Advertisement.ManufacturerData.Count > 0)
    {
        var data = args.Advertisement.ManufacturerData[0];
        if ( data.CompanyId == 0x004c && data.Data.Length >= 23)
        {
            byte[] ibeacon = new byte[data.Data.Length];
            DataReader.FromBuffer(data.Data).ReadBytes(ibeacon);
            if (ibeacon[0] == 0x02 && ibeacon[1] == 0x15)
            {
                byte[] uuid = ibeacon[2..18];
                byte[] major = ibeacon[18..20];
                byte[] minor = ibeacon[20..22];
                byte txpower = ibeacon[22];

                int majorvalue = major[0] * 256 + major[1];
                int minorvalue = minor[0] * 256 + minor[1];
                Console.WriteLine($"{time} [{tohex(uuid)}] {rssi} dBm {mac} "
                    + string.Format("{0:x04}", majorvalue) + " "
                    + string.Format("{0:x04}", minorvalue) + " "
                    );
            }
        }
    }
    string tohex( byte[] data )
    {
        return BitConverter.ToString(data).Replace("-", "").ToLower();
    }
}

コンソール出力のためにあれこれやっていますが、受信だけなれば数十行で済みます。

1. BluetoothLEAdvertisementWatcher クラスのインスタンスを作成する
2. ScanningMode プロパティを Passive に設定する
3. Received イベントにコールバック関数を設定する
4. Start メソッドでスキャンを開始する

コールバック内は

1. args.Advertisement.ManufacturerData プロパティで、メーカー固有データを取得する
2. CompanyId プロパティで Apple の 0x004c か確認する
3. Data プロパティでデータ本体を取得する
4. iBeacon フォーマットに沿ってデータを解析する

iBeacon を判別するのは ManufacturerData の先頭にある

  • Apple Company ID (0x004c)
  • iBeacon Type (0x02)
  • iBeacon Length (0x15)

の部分でチェックします。

iBeacon 自体の UUID を取得して、どこで発信されたものかを記録していきます。ここでは、iBeacon ならば何でも受信していますが、大抵は目的の UUID つまり、既にアプリが知っている UUID だけを受信する設計のはずです。
FolkBers の場合は、ひとつの UUID を使って、Major と Minor で場所を固有ID(Temp ID)にして使っています。

dotnet run で実行すると、コンソールに出力します。

BluetoothLEScanningMode.Passive と Active の違いは、Active の場合はデバイス名要求などの追加のパケットを送信することです。iBeacon の場合は受信だけで良いので Passive で十分です。GATT でコネクションをするときは Active にします。

Windows では Scan Window を変えられるのか?

BluetoothLEAdvertisementWatcher クラスには Scan Window を変えるプロパティはありません。なので、受信精度を変更することができないので、実測して受信状態を確認することになります。

ちなみに Android の場合では SCAN_MODE_LOW_POWER や SCAN_MODE_BALANCED のように代替ですがスキャン頻度を変えることができます。以前、バッテリー消費を抑えるために SCAN_MODE_LOW_POWER を使ったことがありますが、遅延が多かった覚えがります。これは後で再試験。

m5stack のような ESP32 ベースのマイコンを使えば、細かく指定ができます。

void setup() {
  BLEDevice::init("");
  BLEScan *scan = BLEDevice::getScan();
  scan->setInterval(0x00A0); // 100ms / 0.625ms
  scan->setWindow(0x0050);   // 50ms / 0.625ms
  scan->setActiveScan(false); // 受信のみなら false, スキャン応答も欲しければ true
  scan->start(0, nullptr, false); // 0=無期限
}

Scan Window の実験をする場合は、こっちで確認したほうが良さそうです。これは後で作成してみます。確か、iBeacon の発信機は作ってみたけど、受信機は作ったことがないので。

カテゴリー: 開発, FolkBears | iBeacon 受信の実際 Windows + C# 版 はコメントを受け付けていません

FolkBears で利用している aware-micro サーバーをビルドして Docker コンテナで動かせるようになるまで

接触確認アプリ FolkBears では、接触データを Aware というサーバーに送信しています。Aware framework https://awareframework.com/ は、モバイルから加速度データとか WiFi 取得のデータとかをうまく収集できる研究用アプリ+サーバーで、結構便利…なんですが、FolkBears の BLE アドバタイズと利用した接触データの収集とはちょっと相性が悪くてですね(これは数年後に気付いたわけですが)、FolkBears 内部では切り離しを進行中です。

が、データ収集部分は Aware を使わないとしても、データ収集をするためのサーバーのほうは aware サーバーでもよいか、ということでそのままになっています。このサーバーコードが aware-micro https://github.com/denzilferreira/aware-micro な訳ですが。さて、これ以前は PHP で書かれていたような気がするのですが、現在は Kotlin で書かれています。Kotlin はまあいいんですが、内部的に Eclipse Vert.x https://vertx.io/ を使っていてですね、ちょっと再構築にコツが入りそうなのです。
というか、1年程前に自前でサーバーを構築しようして失敗したものだから、面倒くさくなって Azure Functions で仮実装して実験的に動かしていました。ごく一部分だけ使うわけだし、FolkBears からのデータアップロードだけ部分なので、C# で書いても大して手間ではなかったのですが。が、内部で SQL Server を使って、暫くほっといていたら月2万円の請求になってしまい(無料枠が1.2万円ほどだたので、実際の支払は8000円ほどです)、これは放置するとアカンなと思った次第です。

本格的にデータ収集するとなるとギガバイト単位の DB になってしまうので考え直さないといけないのですが、実験データ程度ならば aws の EC2 + t3.micro でもいいはずです。まあ、安く済ませるならば、さくらの VPS を借りて PHP で実装したほうがいいのですが、それば別の機会にでも。

以下、試行錯誤も含めて作業ログ的に書き進めていきます。

docker-compose.yml の作成

aware-micro https://github.com/denzilferreira/aware-micro に Dockerfile の例があるので Ubuntu + Docker で構築することを考えます。

FROM openjdk:11

# Set the location of the verticles
ENV VERTICLE_HOME /usr/verticles

# Set the name of the verticle to deploy
ENV VERTICLE_AWARE_JAR micro-1.0.0-SNAPSHOT-fat.jar
ENV VERTICLE_AWARE_CONFIG aware-config.json

EXPOSE 8080

# Set vertx option
ENV VERTX_OPTIONS ""

# Copy your verticle and configuration to the container
COPY $VERTICLE_AWARE_JAR $VERTICLE_HOME/
COPY $VERTICLE_AWARE_CONFIG $VERTICLE_HOME/

WORKDIR $VERTICLE_HOME
ENTRYPOINT ["sh", "-c"]
CMD ["exec java -jar $VERTICLE_AWARE_JAR"]

git から aware-micro のコードを持ってきてビルドするのではなくて、あらかじめ micro-1.0.0-SNAPSHOT-fat.jar を作成しておく方式です。設定ファイルとして aware-config.json も必要そうです。以前は、これをどうやってつくるのか?が分からなかったのですが、aware-micro https://github.com/denzilferreira/aware-micro の readme に書いてありました。

こっちのほうは、実際にサーバーを動かす方

cd aware-micro
./gradlew clean build run

こっちのほうが、*.jar ファイルを作る方

cd aware-micro
./gradlew clean build shadowJar

build/lib/micro-1.0.0.SNAPSHOT-fat.jar というファイルができあがります。build.gradle の kotlinOptions.jvmTarget を最新の 17 にしていますが通ります。dependencies にあるライブラリのバージョンが古いかもしれませんが、ひとまずこのままにしておきます。

Copilot を使って docker-compose.yml を作って貰います。

version: "3.9"

services:
  mysql:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: aware
      MYSQL_USER: aware
      MYSQL_PASSWORD: awarepass
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"]
      interval: 10s
      timeout: 5s
      retries: 5

  aware-micro:
    build:
      context: dev-local
      dockerfile: Dockerfile
    image: aware-micro:dev
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      VERTX_OPTIONS: ""
    ports:
      - "8080:8080"
    volumes:
      - ./dev-local/aware-config.json:/usr/verticles/aware-config.json:ro
    restart: unless-stopped

volumes:
  mysql-data:

volumes 部分を変更して、aware-config.json と micro-1.0.0-SNAPSHOT-fat.jar をコピーできるようにします。このファイルは aware-micro プロジェクトの方からコピーします。

    volumes:
      - ./aware-config.json:/usr/verticles/aware-config.json:ro
      - ./micro-1.0.0-SNAPSHOT-fat.jar:/usr/verticles/micro-1.0.0-SNAPSHOT-fat.jar:ro

aware-config.json の server の設定を変えておきます。データベース名は docker-compose.yml のほうに合わせました。database_host は docker-composer にある “mysql” を使う筈。

{
  "server" : {
    "database_engine" : "mysql",
    "database_host" : "mysql",
    "database_name" : "aware",
    "database_user" : "aware",
    "database_pwd" : "awarepass",
    "database_port" : 3306,
    "server_host" : "http://localhost",
    "server_port" : 8080,
    "websocket_port" : 8081,
    "path_fullchain_pem" : "",
    "path_key_pem" : ""
  },
  "study" : {
    "study_key" : "4lph4num3ric",
    "study_number" : 1,
    "study_name" : "AWARE Micro demo study",
    "study_active" : true,
    "study_start" : 1770433225185,
    "study_description" : "This is a demo study to test AWARE Micro",
    "researcher_first" : "First Name",
    "researcher_last" : "Last Name",
    "researcher_contact" : "your@email.com"
  },
  "sensors" : [ {
    "sensor" : "accelerometer",
    "title" : "Accelerometer",

micro-1.0.0-SNAPSHOT-fat.jar の作成

./gradlew clean build run のほうはサーバー立ち上げなので 8080 番でポート待ちです。

*.jar ファイルを作るのは ./gradlew clean build shadowJar のほう

aware-config.json ファイルは ./gradlew clean build run あるいは ./gradlew run で自動生成されます。

Docker コンテナを作成&実行

ひとまず Windows 上で docker compose up -d を試します。

Javaのランタイムバージョンでこけるので、Dockerfile の先頭を変えておきます。

FROM eclipse-temurin:17-jre

micro-1.0.0-SNAPSHOT-fat.jar をビルドしたときのバージョンとあわせておきます。

無事 Docker コンテナが立ち上がれば ok です。私の環境の場合、ホストPCに MySQL が入っているので、”3307:3306″ にしておきます。

MySQL Workbench で接続できるところまで確認します。

ブラウザから http://localhost:8080/ のように接続すると、こんな形に出れば aware サーバーが動いています。

テーブルを作成する

データベースにテーブルを作成しておきます。もともと aware-micro にはテーブルを作成する機能があるのですが、これがコメントアウトされています。まあ、URL を通してばかすかテーブルを作られてしまっても困るので、ガードしてあります。

その中で createTable メソッドがあるので、次のようにテーブル作ります。

CREATE TABLE IF NOT EXISTS `$table`
(
  `_id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 
  `timestamp` DOUBLE NOT NULL, 
  `device_id` VARCHAR(128) NOT NULL, 
  `data` JSON NOT NULL, 
  INDEX `timestamp_device` (`timestamp`, `device_id`)
)

`$table` のところは実際に作成するテーブル名をいれます。クライアントから aware-micro に送られたデータは、data カラムに入ります。data カラムは JSON 形式なのでとにかく JSON にしておくと何でも突っ込めます。逆に言えば JSON 形式しか突っ込めません。

クライアント(FolkBears)からデータを送信する

aware-micro サーバーが出来上がったところで、データを送信してみます。aware-micro に送信するときにフォーマットがちょっとややこしくて、

  • POST メソッドを使う
  • content-type を x-www-form-urlencoded 形式で送る
  • device_id パラメータが必須
  • JSON 形式は必ず配列にして data パラメータで送る(複数データが前提になっている!)

という形式になっています。
curl コマンドで送信するときは以下の形式です。

curl -X POST "http://localhost:8080/index.php/1/4lph4num3ric/sample/insert" `
  -H "Content-Type: application/x-www-form-urlencoded" `
  -d "device_id=xxx" `
  -d 'data=[{"timestamp": 1, "name": "masdua"}]' 

URL がまたややこしくて、aware-config.json に記述した、study_number と study_key を使います。index.php は昔 PHP で作成したときの名残りっぽいです。たぶん、もともとは加速度センサーなどの情報を一気に入れるようになったものを、micro にして汎用化したんじゃないかなぁと。

呼び出す URL の形式はこんな感じ

/index.php/:studyNumber/:studyKey/:table/:operation

  • index.php: 固定
  • :studyNumber: aware-config.json の study_number
  • :studyKey: aware-config.json の study_key
  • :table: 操作するテーブル名
  • :operation: 操作方法(insert, update など)

で、無事データが insert されると、こんな風になります。

ひとまず、aware-micro の Docker コンテナに送信できたので、一旦終了。

FolkBears Android 版からの送信

この方式だと微妙に面倒なので、retrofit2 ライブラリを使っています。

package jp.mamori_i.app.webapi

import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST

interface AwareService {
    @FormUrlEncoded
    @POST("trace_data/insert")
    fun uploadData(
        @Field("device_id") device_id: String,
        @Field("data") data: String
    ): Call<Void>

    @FormUrlEncoded
    @POST("trace_data/query")
    fun queryData(
        @Field("device_id") device_id: String,
        @Field("start") start: Long,
        @Field("end") end: Long
    ): Call<String>


    @FormUrlEncoded
    @POST("temp_user_id/insert")
    fun uploadTempUserId(
        @Field("device_id") device_id: String,
        @Field("data") data: String
    ): Call<Void>

    @FormUrlEncoded
    @POST("temp_user_id/query")
    fun queryTempUserId(
        @Field("device_id") device_id: String,
        @Field("start") start: Long,
        @Field("end") end: Long
    ): Call<String>

    @FormUrlEncoded
    @POST("deep_contact_user/insert")
    fun uploadDeepContactUser(
        @Field("device_id") device_id: String,
        @Field("data") data: String
    ): Call<Void>

    @FormUrlEncoded
    @POST("deep_contact_user/query")
    fun queryDeepContactUser(
        @Field("device_id") device_id: String,
        @Field("start") start: Long,
        @Field("end") end: Long
    ): Call<String>

}

object RetrofitClient {
    private const val BASE_URL = "https://aware....jp/index.php/1/folkbears/"

    val awareService: AwareService by lazy {
        retrofit2.Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(retrofit2.converter.scalars.ScalarsConverterFactory.create())
            .build()
            .create(AwareService::class.java)
    }
}

実際の呼び出しは TraceDataUpload.upload で。

package jp.mamori_i.app.webapi

import com.google.gson.Gson
import jp.mamori_i.app.data.TraceDataEntity
import jp.mamori_i.app.webapi.RetrofitClient


class TraceDataUpload
{
    /**
     * Aware に POST 送信する body を作成する
     */
    fun fromTraceDataList(items: List<TraceDataEntity> ) : String {
        if ( items.isEmpty() ) {
            // データがない場合は空配列を返す
            return "[]"
        }

        /** 
        * 送信用の TraceData のデータクラス
        */
        data class TraceData(
            val temp_id: String,
            val timezone: Int,
            val timestamp: Long,
            val tx_power: Int,
            val jsonVersion: Int,
            val os: String,
            val rssi: Int,
            val label: String
        ){}

        var lst = mutableListOf<TraceData>()
        items.map {
            lst.add( TraceData(
                temp_id = it.tempId,
                timezone = 9,
                timestamp = it.timestamp,
                tx_power = 0,
                jsonVersion = 0,
                os = "android",
                rssi =  it.rssi,
                label = ""
            ))
        }
        return Gson().toJson(lst)
    }

    fun upload( deviceId: String, data: String) : Boolean {
        val response = RetrofitClient.awareService
            .uploadData(device_id = deviceId, data = data )
            .execute()
        return response.isSuccessful
    }

カテゴリー: 開発, FolkBears | FolkBears で利用している aware-micro サーバーをビルドして Docker コンテナで動かせるようになるまで はコメントを受け付けていません

markdown で書いて LibreOffice Writer でチェックして wordpress に貼り付けるまで、の計画

技術系の原稿を書くときに、何を使っているかと言うと最近はもっぱら vscode + markdown 形式です。まあ、markdown 形式のみで済むかと言うとそうではなくて、原稿の提出前に Word に貼り付けてチェックをしています。もともと Word の文書チェックの機能を使っていたのと、画面キャプチャのチェックに見た感じのものが必須だからですね。以前はテキストのみで書いていたことも多いのですが、提出した後に画像ファイル番号のずれが多くて、なかなか大変だったので事前に見た目でチェックしようという発想があります。つまりは、私が原稿を書くときに画像ファイル番号を間違えているわけですが。
最近では、markdown 形式のプレビュー機能を使ってチェックする場合も多いのですが、図の矢印とか吹き出しとかもあるので、やはり Word に貼り付けることが多いです。

で、同じパターンで markdown 形式で書いたものを wordpress に貼り付けたいわけですが、これが意外と面倒くさいです。
たいていは、vscode の markdown の原稿からコピペで貼り付けます。

  • 余分な空行がはいる(パラグラフの違い)
  • 箇条書きが1行ずつパラグラフになる
  • 画像が張り付かない
  • 見出しは太文字になる(なぜか、これは対応している)
  • 表は貼り付かない
  • コードブロックは貼り付かない

という問題があります。まあ、workdpress 自身、付属のビジュアルエディタで書くのベターなのでそれでもいいのですが、オフラインで vscode で書いたほうが編集がやりやすいのです。参照するコードとかが色々あるので。

markdown から LibreOffice Writer へ変換する

実験的に ExcelLikeUno ライブラリに connect_writer 関数を生やして、LibreOffice Writer に接続する機能を追加したのが次のコードです。
実は Writer 用に WriterDocument クラスを定義しただけで、paragraph まわりは実装していません。そのまま UNO API を使っています。ExcelLikeUno のほうで完全に網羅しなくても、網羅しきれないところは UNO API を直接使えばいいか、というわけでこの程度ならが書けなくもないか(ほとんど Copilot が書いていますが)というところです。

import uno
from excellikeuno import connect_writer

(desktop, doc) = connect_writer()
# パラグラフを取得して表示する

text = doc.getText()
print("Document Text Paragraphs:" + str(text))

paragraphs = text.createEnumeration()

# LibreOffice text implements enumeration instead of random access.

codeblock = False

while paragraphs.hasMoreElements():
    para = paragraphs.nextElement()
    # print(para.getString())

    # 先頭で ``` 始まるとコードブロックの開始
    if codeblock == True:
        # コードブロックスタイルを適用
        para.setPropertyValue("ParaStyleName", "コード")
        # コードブロック終了判定
        if para.getString().startswith("```"):
            codeblock = not codeblock
        continue
    elif para.getString().startswith("```"): 
        codeblock = not codeblock
         # コードブロックスタイルを適用
        para.setPropertyValue("ParaStyleName", "コード")
        continue

    # 先頭が # の場合、見出しとみなす
    if para.getString().startswith("#"):
        title = para.getString().lstrip("#").strip()
        print(f"Found title: {title}")
        
        # タイトルスタイルを適用
        style_families = doc.raw.getStyleFamilies()
        paragraph_styles = style_families.getByName("ParagraphStyles")
        title_style = paragraph_styles.getByName("Title")
        
        # 先頭が # のみの場合は "見出し1"
        # 先頭が ## の場合は "見出し2" とする
        # 先頭が ### の場合は "見出し3" とする
        level = len(para.getString()) - len(para.getString().lstrip("#"))
        if level == 1:
            para.setPropertyValue("ParaStyleName", "Heading 1")
        elif level == 2:
            para.setPropertyValue("ParaStyleName", "Heading 2")
        elif level == 3:
            para.setPropertyValue("ParaStyleName", "Heading 3")

    # 先頭が - の場合、箇条書きとみなす
    elif para.getString().startswith("-"):
        # 先頭の - を削除
        para.setString(para.getString().lstrip("-").strip())
        # 箇条書きスタイルを適用
        para.setPropertyValue("ParaStyleName", "List 1")

    # 先頭が ![ の場合は画像とみなす
    elif para.getString().startswith("!["):
        # 画像のパスを取得
        start_idx = para.getString().find("](") + 2
        end_idx = para.getString().find(")", start_idx)
        image_path = para.getString()[start_idx:end_idx]

        base_dir = "D:/work/blog/doc/"
        image_path = base_dir + image_path
        # 画像を挿入
        graphic = doc.raw.createInstance("com.sun.star.text.GraphicObject")
        graphic.GraphicURL = f"file:///{image_path}"
        
        print(f"Inserting image: {image_path}")
        # graphic.AnchorType = uno.getConstantByName("com.sun.star.text.TextContentAnchorType.AS_CHARACTER")

        text.insertTextContent(para.End, graphic, False)

        # 画像挿入後、元の段落を削除
        # text.removeTextContent(para)

変更前

変更後

見出し程度ならば手作業とかわらないだろう、ってのもありますが、表とコード、画像の貼りこみ位ができれば確認するにはよいかな、と。現状、図形の貼りこみは小さくなってしまっていますが、図を広げると貼りこまれていることがわかります。 これは、もうちょっと続きを作るのと、Writer 対応の ExcelLikeUno ライブラリを整備に使います。

カテゴリー: 開発, LibreOffice | markdown で書いて LibreOffice Writer でチェックして wordpress に貼り付けるまで、の計画 はコメントを受け付けていません

 BLE アドバタイズのパケット/iBeacon の詳細

なかなか、実際のプログラムコードに至りませんが、その前の前提を知っておいたほうがいいので少しずつ書いていきます。これ、Android や iOS で BLE ライブラリと使って Flutter とか React Native で作るときはさっくりと通していいのですが、m5Stack とか ESP32 とか使って自分で BLE 周りを制御しようとすると途端に必要な知識になっているので(知らないと落とし穴にはまる)、前提として知っておいたほうがいいです。

BLE パケットの Protorol Data Unit (PDU)

BLE パケット全体の大きさは、たかだか 47 バイト程度です。データ通信の場合はもっと長くなるのですが、ここで扱うのは BLE アドバタイズなので、非常に短いデータ量で済みます。データ量が少ないので、0.4 ms 以下という短い単位で送信/受信が可能です。

だから、受信しようと思えば Scan Window を広く取っておけば、いつでも BLE アドバタイズを受信できる確率は高くなります。ただし、それは概念的なので、実際的にはハードウェア的に Scan Window で待ち受けている時間とあ Scan Interval で受信できない時間が交互にやってきます。これ、バッテリーの問題なのか、ハードウェアの制限なのかはよくわからないのですが(つまり、無限に Scan Window を広げられるか私にはわかりません)、現実n BLE のハードウェアはそういう構造になっているということです。

さて、BLE パケットの内、データを乗せることができる Protocol Data Unit (PDU) の詳細をみていきます。

BLUETOOTH CORE SPECIFICATION Version 6.0 の Vol 6 Low Energy Controller の Part B LINK LAYER SPECIFICATION の 2 AIR INTERFACE PACKETS あたりから書いてあります。

全部で 4000 ページ位あるのですが、Core Specification 6.0 | Bluetooth® Technology Website https://www.bluetooth.com/specifications/specs/core60-html/ からダウンロードが可能です。通読するのは大変なので、適度にピックアップして読みます…が、この部分は PDU の部分だけじゃなくて、BLE パケット全体の仕様が書いているので、肝心のデータ部分がわかりません。多分、2.3.1.1 ADV_IND あたりの AdvData の部分のフォーマットだと思うのですが、これは後で調べ直し。

要は、BLE アドバタイズでも色々なヘッダーがあって種類があるのですが、iBeacon のようなフォーマットを送っている場合は ADV_IND の種類を流しておいて、その中のデータ = AdvData を送受信側でプロトコルとして決めるわけです。物理層/リンク層のもうひとつ上のレイヤーになります。

この AdvData の中身を決めているのが、Supplement to the Bluetooth Core Specification https://www.bluetooth.com/specifications/specs/core-specification-supplement-10/ です。これも PDF 形式でダウンロードができます。

この中の Part A DATA TYPES SPECIFICATION にプロトコル仕様が書いてあります。

これはサービス UUID の書き方ですね。

まあ、表だけ並べられても解り辛いので、いくつかのサンプルデータも載っています。

これはデバイス名をアドバタイズする場合です。

BLE のフォーマットはちょっと変わった形式になっていて、

  • データ長
  • データタイプなど
  • データ自身

という順番で並んでいます。データ長が1バイトと制限になるので(最大 255 バイト)とは思います。
上のデータを書き直すとこんな感じになります。

最初の 0x02 のように、フラグだけ並んでいる場合と、次の 0x0A のようにデバイス名のような可変長のデータが続く場合があります。デバイス名は特殊でデータ長が入っているのですが、大抵は Service UUID のように 16 バイトとか 4 バイトとか決まった長さのデータが続く場合が多いです。

なので、BLE アドバタイズ自身は Data types specification の書式に従っていれば自由に BLE アドバタイズのデータ送信が可能です。実際、Apple の iBeacon のフォーマットや、Google の Eddystone のフォーマットもこの書式に従っています。おそらく BLE 端末の機器メーカーも流しているのかはこれかな、と思うのですが、定かではありません。

BLE アドバタイズのフォーマットは自由に決められるということは解ったのですが、接触確認アプリのように既存の OS や BLE ライブラリを使う場合にはそれほど自由ではありません。と言いまうsか、Apple の場合は iBeacon フォーマットしか使えません。Android の場合は、もう少し自由に作れますが、iPhone との共同運用を考えると iBeacon フォーマットしか使えないというのが現実的な選択肢です。

もちろん、m5stack とか ESP32 を使て独自の BLE アドバタイズのフォーマットを使えば自由に作れます。Android の場合も多少の制限がありますが、Android 同士であれば結構自由に作れます。これは先行き解説していきます。

話を元に戻すと、FolkBears のコレクションレスモードの場合は、iBeacon のフォーマットを使っています。この iBeacon フォーマットは何処に記述されているのでしょうか?

https://developer.apple.com/ibeacon/ から Artwork and specifications をダウンロードして、Proximity Beacon Specification を開くと正式なものがあります。

これ、なかなか探してもわからなかったのですが、こんなところにあったんですね。

これだと解り辛いので、書籍や Wikipedia の iBeacon の記事を参考にするとわかりやすいです。

iBeacon – Wikipedia https://en.wikipedia.org/wiki/IBeacon

これを図に書き直すとこんな感じになります。

これが慣れないと難しいのですが、

  • 最初の3バイトは、BLE アドバタイズのヘッダー情報
  • 次の4バイトは、Maniufacturer Specific Data のデータタイプを示すための Length + 0xFF + Company ID (Apple の場合は 0x004C) が続く
  • 次の 2 バイトは、Apple 決めた iBeacon を示すための固定値 0x02 と 長さ(0x15)
  • 次の 16 バイトは、iBeacon の UUID
  • 次の 2 バイトは、Major
  • 次の 2 バイトは、Minor
  • 最後の 1 バイトは、Tx Power

という訳で2段階に分かれています。

BLE の規格としては、Manufacturer Specific Data を示す 0xFF のフラグと、その後の Company ID までで、その後は各社自由に作れます。自由に作れるといっても、アドバタイズのデータ全体が 31 バイトまでの制限なので、さらに小さくなります。

さらに、Company ID 自体は Bluetooth SIG が管理しているので、お金を払っている Apple 社は 16 ビット(2バイト)の Company ID を持っているのですが、実験的には 0xFFFF などを使わないといけません。
このあたりの細かいところは、後で ESP32 などで実装するときに詳しく解説する予定です。すくなくとも m5stack などの BLE ライブラリを使うと、かなり自由に BLE アドバタイズのフォーマットが作れるので iBeacon フォーマットにこだわる必要はありません。

iBeacon フォーマットの区切りがややこしいのですが、最初の Lnetgh + Flag の組みあわせは BLE 規格のほうで、 Manufacturer ID 以降の、Sub Type + Sub Length のほうの組み合わせは iBeacon 規格を出している Apple 独自の仕様です。ここで、Lenth と Type が逆になっているのはそのせいです。これは当悩んだのですが、つまりは内部規格ということです。

他にも Google の定義する AltBeacon フォーマットとか Eddystone フォーマットとかもありますが、これはまた別に解説します。同じ Beacon フォーマットとはいえ、ちょっとずつ違っているのは各社設定してしまっているからです。

とはいえ、Beacon タグからのデータは Apple だげが受信するものでもなく、Beacon タグの作成自体も Apple だけが作るわけでもありません。

  • 非 Apple 製の Beacon タグもある
  • 非 Apple 製の iBeacon 受信アプリもある

という事情があります。互換性というわけです。

iBeacon データを実測する

iBeacon フォーマット詳細がわかったところで実測をしてみましょう。

実測とはいえ、きちんとした iBeacon 受信機を作るところからスタートしないといけないのですが、ここでは私が以前したデータを使います。と、言いますか、ここからが問題があるのです。

iOS から発信された iBeacon データ

02011A1AFF4C00021590FA7ABEFAB6485EB7001A17804CAA13A6D0CFC1C5

これを先の表に従って分解していきます。

iBeacon UUID: 90FA7ABEFAB6485EB7001A17804CAA13 が取得できればひとまず ok です。
このデータ自体、Major や Minor、Tx Power も取得できるので、iBeacon フォーマットとしては正しいことがわかります。
実は、実測すると分かるのですが 3 バイト目のFlag データの 0x1A の部分は、iBeacon 発信機によって異なります。ここは BLE アドバタイズに仕様なので、先の iBeacon 仕様の例のように同じ値になるとは限りません。つまりは、内部的に Maniufacturer Specific Data の部分は同じ Apple の iBeacon の規格を使っているとしても前後の BLE のフラグは発信する機種/メーカーによって異なります。

これを Android が iBeacon データを発信したときのデータを見てみましょう。

1AFF4C00021590FA7ABEFAB6485EB7001A17804CAA13F15FA5E2C5

実は、先頭の 3バイト(02011A)がありません。

この現象は Android の org.altbeacon.beacon ライブラリと使ったときに発生します。

private fun startBeaconTransmission() {
    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")
    beaconTransmitter = org.altbeacon.beacon.BeaconTransmitter(context, beaconParser)
    beaconTransmitter?.startAdvertising(beacon, object : AdvertiseCallback() {
        override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
            Log.d("BeaconService", "iBeacon 発信開始")
        }
        override fun onStartFailure(errorCode: Int) {
            Log.e("BeaconService", "iBeacon 発信に失敗: $errorCode")
        }
    })
}

この iBeacon 形式のデータを iOS 側で受信させると、うまく受信できるので iBeacon 発信機としてうまく動いています。先頭部分の 02011A がなくても iBeacon フォーマットとしては問題ない、ということなのです。
ただし、この iBeacon データを、android.bluetooth.le.ScanFilter でフィルタリングしようとすると、うまくフィルタリングできない、という問題があります。なんだかよくよくわからないので、FolkBears では Maniufacturer Data の中身をシークしているのですが、ここは現在のところ回避策をとっているところです。

ひとまず、この BLE 規格の部分と iBeacon フォーマットを把握しておけば、BLE アドバタイズで受信したデータを解析できることがわかります。このあたり、m5stack や ESP32 で BLE アドバタイズを受信するときにも使える知識です。

カテゴリー: 開発, FolkBears |  BLE アドバタイズのパケット/iBeacon の詳細 はコメントを受け付けていません

BLE 近接通信データを使った二世代前のコンタクトトレースを探る

もともと、BLE による接触確認アプリに興味を持ったのは、アプリ自体が保持している近接データを駆使すれば、二世代前の接触者まで追跡できろうだろうと思ったわけですし、それをやると思ったんですよ。

コンタクト・トレーシング – Wikipedia https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%B3%E3%82%BF%E3%82%AF%E3%83%88%E3%83%BB%E3%83%88%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%B3%E3%82%B0

2020年当時に日本の保健所が逼迫していたのが、感染者の聞き取り調査の部分です。聞き取り自体がアナログでしかできないし、その追跡自体も人海戦術のようでした。基本的な感染症数理モデルの SIR 型モデルを知ったのもこの頃ですが、統計学のそれも興味の範囲ではあったけど、直接 IT 技術者として手が付けられそうなのは、この接触確認アプリの近接データの扱いです。

結論から先に言うと、Goolge/Apple の Exposure Notification API を使った場合には二世代前の接触者などを追跡することはできません。サーバーに集められるのは、Temporary Exposure Keys (TEK) と呼ばれる感染者の端末が生成した一時的な鍵情報なだけで、接触自体はアプリ内で確認することになるからです。
これは個人情報保護としてはいいのですが、先にあるように保健所のトレーシング業務を支援することは不可能なわけです。ある意味 EN API の設計の失敗(実用度の失敗でもありますが)はここにあると思っています。

俺の考える最強のコンタクトレース…というか、日本の保健所のヒアリングがこんな感じになっていたと思うのです。いわゆる、新型コロナウィルスの場合もインフルエンザの場合も、感染者が居て集団で感染するクラスターのパターンが顕著に感染が広がります。接触感染じゃななくて空気感染(詳しい事は省略したうえで)なのがミソです。学校や老後施設での集団感染のことです。結局のところ、集団でいたときにマスクで防護するのが一番効率がよいのですが、じゃあ、まだマスクが主要でなかったときに、クラスター感染をどうやって見つけるのか、というのが当初の問題にありました。

そこで、保健所でヒアリングをして、感染源であるクラスターを探して、そのクラスターに居た人に注意喚起をしよう、というのが目的した。

ただし、そのヒアリングが人力であったし、保健所の職員自体も足りなかったので非常に負担であったという事実があります。いまだと、感染自体の報告義務がなくなったので、ヒアリング自体がどうなっているか不明ではありますが。
ただし、将来的に同じパターンで感染が広がったときに「ヒアリング」という手段でよいのか? という問題があります。

IT 技術者としては、ここをなんとか自動化できるのではないか、と考えるわけです。

1. A で感染者が発覚する
2. A にヒアリングして、過去の B の集団が感染が疑われる
3. A にヒアリングして、最近の C の集団が感染が疑われる
4. B の感染疑いから、遡って C の感染疑いが発覚する
5. 過去の D の集団は、2 週間(感染期間)より前なので除外する
6. 未来の E の集団は、2 週間以内なので感染疑いとする
7. 未来の F の集団は、2 週間後なので除外する

基本的に感染者 A が発生したときは、その周辺の人達(家族や会社の同僚など)に注意喚起をするわけで、ちょっと前に合っていた B の集団に注意を喚起するわけです。
同時に、感染者 A がうろうろ歩き回ることを考えると、将来的に接触しそうな E に集団にも中期喚起が必要なわけです。まあ、新型コロナウィルスの場合は監禁ということになっていたので、うろうろ歩き回ってはいけないのですが。このあたりは、接触確認アプリの目的としてはどうなのでしょう、というところがあります。

で、実際のところ機能していたのは B の集団に対する注意喚起であって、感染者 A が感染登録をすると、B の集団の持っているスマホに「感染者に接触した可能性あります」という通知が送られるというのが EN API の基本的な仕組みです。
プライバシーの観点から、誰が感染したかどうかは分からないようになっています。が、「感染者に接触した可能性があります」という文言自体があまりにも曖昧なので、結果的にどう扱っていよいか分からなかったのが当時の実情です。結果的に「接触確認アプリが使えない」という批判を浴びたのもこういうところでしょう。

実際のところ、突き合わせは集団 B の人達が持っているスマホ内で照合が行われるので、集団 B の人達が感染者 A がその人であることを知ることはできません。この制限は、あったほうが良いかったのか、ないほうが良かったのかは不明ですが、EN API の設計としてはこうなっているわけです。

で、保健所でトレースをとっているのは、

1. 感染者 A が集団 B に感染させている可能性
2. 感染者 A が集団 B の誰かに感染させられた可能性

の二つがあります。新型コロナウィルスの場合、無症状状態があってその間に感染させている期間があるという前提となっているので(実はこれはインフルエンザでもあることが最近知られています)、感染者 A は自分が感染していることを知らずに、集団 B の誰かに感染させている可能性がある、という想定です。と、同時に、集団 B が既に感染をしていてその誰かからか感染させられたという可能性です。
これは、感染したという事実は、必ずしも保健所に報告されないことがあるためです。あるいは、無症状のまま感染している状態もあるので、集団 B には潜在的な感染者がいるという可能性を考えるわけです。

新型コロナウィルス自体は、人から人に伝播するわけですから、感染者 A が集団 B に感染させられたときには、その以前いた集団 C にも感染者がいる可能性があります。
こんな風に、感染の経路を保健所の職員がヒアリングして突き止めていたわけですが…果たして、接触確認アプリはどのくらい貢献できたでしょうか?

というか、EN API の設計上で、このトレースは取ることができたでしょうか? という疑問があります。

先に書いた通り、EN API の設計上、個人のスマホ内でしか照合をしないので、感染通知が出せるのは集団 B の人達に対してだけです。しかも、どの時間に誰に接触したかを知らせることはしないので(サーバーとクライアントのデータを照合すれば特定可能ではありますが…機能上できません)、感染者 A がうろついた場所でしか注意喚起ができません。いや、むしろ、注意喚起ばかりが大きくて、迷惑だったという事実があります。まあ、端的に言えば、役に立たなかったのです。

これは、EN API の設計に引きずられてしまったという要因もあります。

先に書いた通り、EN API の設計上、感染者 A がすれ違った集団 B の人達というあいまいな特定の仕方しかできないので、アプリ自体がどう工夫してもこの制限を超えることができないのです。

では、もし、EN API の設計を変えたとして、保健所のようなトレース(集団 B や集団 C までの範囲の特定、そして 集団 D には通知しないなど)ができる程度まで、データの照合ができるとしたら、どのような仕組みを考えればよいでしょうか? というのが FolkBears の課題であると私は思っています。

当然、EN API のようにプライバシーを守る必要があります。コンタクトトレースの場合を取る場合は、一番乱暴な方法としては GPS の位置情報をサーバーに送ってしまう、という方法があります。ですが、これはプライバシーの観点からは最悪です。感染者 A の行動履歴だけでなく、集団 B や C までが丸裸になってしまうからです。実際、GPS の位置情報を利用したアプリで、犯罪者の行動履歴を取ることもあります。

では、GPS の位置情報ではなく、単純に BLE を使って近接した(いわゆる iBeacon のように)だけを使って、どのくらいまで保健所のトレースと同じことができるでしょうか?

カテゴリー: 開発, FolkBears | BLE 近接通信データを使った二世代前のコンタクトトレースを探る はコメントを受け付けていません