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 パーマリンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

*