前回 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 は、かなり受信頻度が落ちる
ことが体感できれば十分です。
