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

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

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

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

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

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

Jetpack Compose で作る

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

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

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

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

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

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

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

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

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

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

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

iBeacon の発信

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

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

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

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

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

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

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

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

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

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

EN API 形式の発信

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


    private var currentCallback: AdvertiseCallback? = null

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

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

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

    private var data: AdvertiseData? = null

    fun startAdvertising() {

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

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

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

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

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

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

Manufacturer Data 形式の発信

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

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

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

	companion object {
		const val TAG = "ManufacturerDataTx"
	}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

動作確認

実際の動きは、

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

Low Power で発信

Low Latency で発信

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

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

参考コード

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

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

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

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

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

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

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

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

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

extension GattClient: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        DispatchQueue.main.async {
            switch central.state {
            case .poweredOn:
                self.bluetoothState = "Powered On"
                print("Bluetooth が有効になりました")
            case .poweredOff:
                self.bluetoothState = "Powered Off"
                self.stopScanning()
                self.disconnectFromPeripheral()
                print("Bluetooth が無効です")
            case .resetting:
                self.bluetoothState = "Resetting"
                print("Bluetooth リセット中")
            case .unauthorized:
                self.bluetoothState = "Unauthorized"
                print("Bluetooth 使用権限がありません")
            case .unsupported:
                self.bluetoothState = "Unsupported"
                print("Bluetooth がサポートされていません")
            case .unknown:
                self.bluetoothState = "Unknown"
                print("Bluetooth 状態不明")
            @unknown default:
                self.bluetoothState = "Unknown"
                break
            }
        }
    }
    
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        
        let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
        
        let discoveredPeripheral = DiscoveredPeripheral(
            peripheral: peripheral,
            name: name,
            rssi: RSSI,
            advertisementData: advertisementData
        )
        
        DispatchQueue.main.async {
            self.peripherals.append(discoveredPeripheral)
            self.scanningStatus = "発見: \(self.peripherals.count)個"
            self.onDiscover?(discoveredPeripheral, Date())
        }
        print("ペリフェラル発見: \(discoveredPeripheral.displayName), RSSI: \(RSSI)")
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        DispatchQueue.main.async {
            self.connectedPeripheral = peripheral
            self.connectionStatus = "接続済み"
        }
        
        print("ペリフェラル接続成功: \(peripheral.name ?? "Unknown")")
        
        // MTU要求を実行
        requestMTU(requestedMTU)
        
        // サービス探索開始
        peripheral.discoverServices([targetServiceUUID])
    }
    
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        DispatchQueue.main.async {
            self.connectionStatus = "接続失敗"
        }
        
        print("ペリフェラル接続失敗: \(error?.localizedDescription ?? "Unknown error")")
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        DispatchQueue.main.async {
            self.connectedPeripheral = nil
            self.connectionStatus = "切断済み"
            self.targetService = nil
            self.targetCharacteristic = nil
        }
        
        print("ペリフェラル切断: \(peripheral.name ?? "Unknown")")
    }
}

Manufacturer Data 形式の受信

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

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

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

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

    private var centralManager: CBCentralManager!

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

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

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

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

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

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


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

実行した様子

参考先

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

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

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 に貼り付けるまで、の計画 はコメントを受け付けていません