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
