task(efd8bad2-98f6-4ca6-b0e2-06d5f557b43f): Add M4 collect & BLE module (scan/connect/parse/excite/queue)

补偿提交:归档自动提交(commitOnArchive)未落盘,由主聊天代为补交 S-M4 全部源码变更(16 新 + 4 改 + 1 删)。
- core:ble:BleDriver/BleConnection 抽象 + SystemBleDriver/FakeBleDriver(演示模式,协议精确样例帧)/SwitchableBleDriver;BleSession 四态状态机(Ready)、MTU 协商、API 33+ 回调签名,修复 notifyBytes 自建 listener 收不到回调的骨架缺陷
- app:feature/collect 九件套(权限分版动线/扫描/连接/会话编排/退避重连/激励下发/MonitorValue payload 入队/测点源 M5 预留接口),Tab.COLLECT 换挂 CollectWorkspaceFragment,删除占位 CollectFragment
- 修正存量协议测试断言:接口文档 140V 系算术笔误,按公式口径应为 140.39
验证::app:assembleDebug BUILD SUCCESSFUL(app-debug.apk 8.6MB);单测 15/15 绿(协议 12 + payload 3)
This commit is contained in:
阿猫
2026-09-04 01:55:48 +08:00
parent 85a144459e
commit 3cb82169b6
21 changed files with 2492 additions and 65 deletions

View File

@@ -15,38 +15,88 @@ import androidx.annotation.RequiresPermission
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
/**
* GATT 连接状态机M4-03 四态口径)。
*
* - [Connecting]connectGatt 已发起,等待链路建立;
* - [Connected]:链路建立,服务发现中;
* - [Ready]服务发现成功MTU 协商由 [BleSession.awaitReady] 内尽力而为完成),
* 可订阅 Notify / 下发写入;
* - [Disconnected]链路断开或服务发现失败status 为 GATT 错误码,会话终结。
*/
sealed interface GattState {
data object Connecting : GattState
data object Connected : GattState
data object ServicesDiscovered : GattState
data object Ready : GattState
data class Disconnected(val status: Int) : GattState
}
/**
* 单设备 GATT 连接会话骨架
* 单条 BLE 连接的驱动级抽象M4 无硬件环境的 FakeBleDriver 演示路线依赖此接口)
* 不泄露 BluetoothGatt 类型,纯 Kotlin 可实现。
*/
interface BleConnection {
/** 连接状态流Disconnected 后连接即终结。 */
val state: StateFlow<GattState>
/** 挂起等待连接就绪(服务发现成功);失败/超时抛异常。 */
suspend fun awaitReady(timeoutMillis: Long = DEFAULT_TIMEOUT)
/**
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
* 帧定界与解析由上层ble-protocol负责本流按 Notify 回调原样发射。
*/
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray>
/** 写特征VM208 激励命令下发)。返回是否写入成功。 */
suspend fun write(
profile: BleUuids.Profile,
data: ByteArray,
timeoutMillis: Long = DEFAULT_TIMEOUT,
): Boolean
/** MTU 协商M4-03设备不支持/协商失败返回 false不阻塞采集帧长均 < 默认 23B。 */
suspend fun requestMtu(mtu: Int): Boolean
/** 主动断开并释放 GATT 资源。 */
fun disconnect()
companion object {
const val DEFAULT_TIMEOUT = 10_000L
}
}
/**
* 单设备 GATT 连接会话(系统 BluetoothGatt 实现)。
*
* 用法S2/S3 业务侧)
* 用法:
* ```
* val session = connector.connect(scanned)
* session.state.filter { it is GattState.ServicesDiscovered }.first()
* session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
* val connection = driver.connect(scanned)
* connection.awaitReady()
* connection.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
* val readings = parser.feed(bytes)
* }
* ```
*
* ⚠ write 采用单挂起桥(单 CompletableDeferred 槽),上层须串行下发
* (采集页 UI 单入口已保证)。
*/
@SuppressLint("MissingPermission")
class BleSession internal constructor(
context: Context,
address: String,
) {
) : BleConnection {
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@@ -62,49 +112,56 @@ class BleSession internal constructor(
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
/** 连接状态流Disconnected 后会话即终结。 */
val state: StateFlow<GattState> = _state
/** 等待服务发现完成(连接后调用一次)。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
suspend fun awaitServicesDiscovered(timeoutMillis: Long = DEFAULT_TIMEOUT) {
withTimeout(timeoutMillis) {
state.filter { it != GattState.Connecting && it != GattState.Connected }.first()
}
}
override val state: StateFlow<GattState> = _state.asStateFlow()
/**
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
* 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
* Notify 字节总线BluetoothGatt 的回调只送达 connectGatt 注册的主 callback
* 实例S0 骨架曾误在 notifyBytes 内自建 listener任何回调都不会路由到它
* 因此字节统一由主 callback 转发到此流,[notifyBytes] 订阅消费。
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
private val notifyBus = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
/** MTU 协商挂起桥onMtuChanged 一次性回填。 */
private var mtuRequest: CompletableDeferred<Boolean>? = null
override suspend fun awaitReady(timeoutMillis: Long) {
val terminal = withTimeout(timeoutMillis) {
state.filter { it is GattState.Ready || it is GattState.Disconnected }.first()
}
check(terminal is GattState.Ready) {
"连接未就绪GATT status=${(terminal as? GattState.Disconnected)?.status}"
}
// MTU 尽力协商到 185两设备帧长 ≤ 27B失败不阻塞采集
requestMtu(NEGOTIATED_MTU)
}
override fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
val characteristic = checkNotNull(
gatt.getService(profile.service)?.getCharacteristic(profile.notifyCharacteristic),
) { "Notify 特征不存在: ${profile.notifyCharacteristic}" }
return callbackFlow {
val listener = object : BluetoothGattCallback() {
@Deprecated("API 33 起走带 characteristic 参数的新回调;骨架期两版共存")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
if (characteristic.uuid == profile.notifyCharacteristic) {
trySend(characteristic.value ?: ByteArray(0))
}
}
}
check(gatt.setCharacteristicNotification(characteristic, true)) {
"开启 Notify 失败: ${profile.notifyCharacteristic}"
}
gatt.writeDescriptor(
characteristic.getDescriptor(DESCRIPTOR_CCCD)?.apply {
value = ENABLE_NOTIFICATION_VALUE
},
)
// 单回调实例仅做字节监听(连接生命周期由主 callback 管理)
val cccd = checkNotNull(characteristic.getDescriptor(DESCRIPTOR_CCCD)) {
"Notify 特征缺少 CCCD 描述符"
}
if (Build.VERSION.SDK_INT >= 33) {
// API 33+ 同步返回 GATT 状态码
check(
gatt.writeDescriptor(cccd, ENABLE_NOTIFICATION_VALUE) ==
BluetoothGatt.GATT_SUCCESS,
) { "写 CCCD 失败" }
} else {
@Suppress("DEPRECATION")
check(
gatt.writeDescriptor(cccd.apply { value = ENABLE_NOTIFICATION_VALUE }),
) { "写 CCCD 失败" }
}
val pump = launch { notifyBus.collect(::trySend) }
awaitClose {
pump.cancel()
runCatching {
gatt.setCharacteristicNotification(characteristic, false)
}
@@ -112,29 +169,63 @@ class BleSession internal constructor(
}
}
/** 写特征VM208 激励命令下发)。返回是否写入成功。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): Boolean {
override suspend fun write(
profile: BleUuids.Profile,
data: ByteArray,
timeoutMillis: Long,
): Boolean {
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
val characteristic = gatt.getService(profile.service)
?.getCharacteristic(profile.writeCharacteristic)
?: return false
val done = CompletableDeferred<Boolean>()
writeListener.completeWith = done
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = data
if (!gatt.writeCharacteristic(characteristic)) {
done.complete(false)
if (Build.VERSION.SDK_INT >= 33) {
// API 33+ 同步返回 GATT 状态码,非 SUCCESS 直接失败
val result = gatt.writeCharacteristic(characteristic, data, WRITE_TYPE)
if (result != BluetoothGatt.GATT_SUCCESS) done.complete(false)
} else {
legacyWrite(characteristic, data, done)
}
return withTimeout(timeoutMillis) { done.await() }
}
/** 主动断开并释放 GATT 资源。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun disconnect() {
@Suppress("DEPRECATION")
private fun legacyWrite(
characteristic: BluetoothGattCharacteristic,
data: ByteArray,
done: CompletableDeferred<Boolean>,
) {
characteristic.writeType = WRITE_TYPE
characteristic.value = data
if (!gatt!!.writeCharacteristic(characteristic)) done.complete(false)
}
override suspend fun requestMtu(mtu: Int): Boolean {
val gatt = gatt ?: return false
val done = CompletableDeferred<Boolean>()
mtuRequest = done
if (!gatt.requestMtu(mtu)) {
mtuRequest = null
return false
}
return try {
withTimeout(BleConnection.DEFAULT_TIMEOUT) { done.await() }
} catch (e: Exception) {
false
} finally {
mtuRequest = null
}
}
override fun disconnect() {
gatt?.disconnect()
}
private fun emitNotify(characteristic: BluetoothGattCharacteristic, bytes: ByteArray?) {
if (bytes != null && bytes.isNotEmpty()) notifyBus.tryEmit(bytes)
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
@@ -152,28 +243,50 @@ class BleSession internal constructor(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
_state.value = GattState.ServicesDiscovered
_state.value = GattState.Ready
} else {
_state.value = GattState.Disconnected(status)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
mtuRequest?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
writeListener.completeWith?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
// API 26~32 走旧签名API 33+ 只走带 value 的新签名(缺失则 33+ 收不到字节)
@Deprecated("API 33 起走带 characteristic/value 的新回调,双版本共存")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
emitNotify(characteristic, characteristic.value)
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
emitNotify(characteristic, value)
}
}
/** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
private val writeListener = object {
lateinit var completeWith: CompletableDeferred<Boolean>
var completeWith: CompletableDeferred<Boolean>? = null
}
private companion object {
const val DEFAULT_TIMEOUT = 10_000L
const val NEGOTIATED_MTU = 185
val WRITE_TYPE: Int = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
/** Client Characteristic Config 描述符Notify 开关)。 */
val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
@@ -184,8 +297,7 @@ class BleSession internal constructor(
}
/**
* GATT 连接入口:按扫描结果建立会话。
* S0 骨架不引 Hilt由 app/S3 按需构造或包装。
* GATT 连接入口:按扫描结果建立会话[SystemBleDriver] 的底层装配件)
*/
class BleConnector(private val context: Context) {

View File

@@ -0,0 +1,23 @@
package com.stec.cmd.core.ble
import kotlinx.coroutines.flow.Flow
/**
* BLE 驱动抽象M4 全动线的硬件无关层):
* - [SystemBleDriver]:系统 BluetoothLeScanner/BluetoothGatt 真实实现;
* - [FakeBleDriver]:演示模式样例帧实现(无 BLE 硬件环境驱动完整 UI 动线);
* - [SwitchableBleDriver]:运行时按演示开关路由。
*
* 本模块保持零 DI 依赖(限定符绑定由 app 壳 BleModule 完成)。
*/
interface BleDriver {
/**
* 扫描目标类型设备(名称前缀过滤由实现负责),命中流同地址覆盖发射。
* 收集端取消即停止扫描。
*/
fun scan(types: Set<DeviceType> = DeviceType.entries.toSet()): Flow<ScannedDevice>
/** 建立连接(异步:状态从 [GattState.Connecting] 起流)。 */
fun connect(device: ScannedDevice): BleConnection
}

View File

@@ -0,0 +1,196 @@
package com.stec.cmd.core.ble
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
/**
* 演示模式 BLE 驱动(无 BLE 硬件环境驱动完整采集动线M4-04/05 样例帧口径):
* - 扫描渐次发射两台演示设备IE-1000 / VM208 200001RSSI 带抖动;
* - 连接:约 0.5s Connected、再 0.6s Ready模拟真实节奏
* - NotifyReady 后每约 600ms 发射**协议精确**样例帧Ie1000FrameParser /
* Vm208FrameParser 可直接解析,数值随时间波动);
* - 写入VM208 激励命令按协议「原样回显」注入 Notify 流作为 ack。
*
* 纯 Kotlin 实现,不触碰 android.bluetooth。
*/
class FakeBleDriver : BleDriver {
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = flow {
while (true) {
DEMO_DEVICES.filter { it.type in types }.forEachIndexed { index, device ->
// 逐台出现 + 周期性重发RSSI 微抖动UI 同地址覆盖刷新)
if (index > 0) delay(DEVICE_APPEAR_INTERVAL)
emit(device.copy(rssi = device.rssi + JITTER.random()))
}
delay(RESCAN_INTERVAL)
}
}
override fun connect(device: ScannedDevice): BleConnection = FakeConnection(device)
private companion object {
val DEMO_DEVICES = listOf(
ScannedDevice(
name = "IE-1000",
address = "02:1A:7F:DE:MO:01",
rssi = -52,
type = DeviceType.IE1000,
),
ScannedDevice(
name = "VM208 200001",
address = "02:1A:7F:DE:MO:02",
rssi = -63,
type = DeviceType.VM208,
),
)
const val DEVICE_APPEAR_INTERVAL = 900L
const val RESCAN_INTERVAL = 2_500L
val JITTER = intArrayOf(-2, -1, 0, 1, 2)
}
}
/** 演示连接:定时器驱动状态机与样例帧流。 */
private class FakeConnection(private val device: ScannedDevice) : BleConnection {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
override val state: StateFlow<GattState> = _state.asStateFlow()
/** 写入回显总线VM208 激励 ack。 */
private val echoBus = MutableSharedFlow<ByteArray>(extraBufferCapacity = 8)
private val startedAt = System.currentTimeMillis()
private var frameLoop: Job? = null
init {
scope.launch {
delay(CONNECT_DELAY)
_state.value = GattState.Connected
delay(SERVICE_DISCOVER_DELAY)
if (_state.value == GattState.Connected) _state.value = GattState.Ready
}
}
override suspend fun awaitReady(timeoutMillis: Long) {
val terminal = withTimeout(timeoutMillis) {
state.first { it == GattState.Ready || it is GattState.Disconnected }
}
check(terminal == GattState.Ready) { "演示连接未就绪" }
}
override fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> = callbackFlow {
val echoPump = launch { echoBus.collect(::trySend) }
frameLoop?.cancel()
frameLoop = launch {
while (isActive) {
trySend(buildSampleFrame())
delay(NOTIFY_INTERVAL)
}
}
awaitClose {
frameLoop?.cancel()
echoPump.cancel()
}
}
override suspend fun write(
profile: BleUuids.Profile,
data: ByteArray,
timeoutMillis: Long,
): Boolean {
delay(WRITE_DELAY)
// 协议约定设备对激励命令原样回显0F 0F 06 0A XX XX 0D 0A
if (data.size == ECHO_COMMAND_LENGTH) echoBus.tryEmit(data.copyOf())
return true
}
override suspend fun requestMtu(mtu: Int): Boolean = true
override fun disconnect() {
frameLoop?.cancel()
_state.value = GattState.Disconnected(FAKE_CLEAN_STATUS)
}
/** 协议精确的样例帧:数值随时间小幅波动,可在 ble-protocol 解析器中完整复原。 */
private fun buildSampleFrame(): ByteArray {
val t = (System.currentTimeMillis() - startedAt) / 1000.0
return when (device.type) {
DeviceType.IE1000 -> {
val pressure = (BASE_PRESSURE + sin(t) * PRESSURE_AMPLITUDE).toFloat()
val temperature = (BASE_TEMPERATURE + sin(t / 3) * TEMPERATURE_AMPLITUDE).toFloat()
ieFrame(pressure, temperature)
}
DeviceType.VM208 -> {
val frequency = BASE_FREQUENCY + sin(t) * FREQUENCY_AMPLITUDE
val temperature = BASE_TEMPERATURE + sin(t / 3) * TEMPERATURE_AMPLITUDE
val voltage = BASE_VOLTAGE + sin(t / 5) * VOLTAGE_AMPLITUDE
vmFrame(frequency, temperature, voltage, QUALITY)
}
}
}
/** IE-1000'Y' + 4B 小端 float 压力 + 'T' + 4B 小端 float 温度 + 0D 0A。 */
private fun ieFrame(pressure: Float, temperature: Float): ByteArray {
val buffer = java.nio.ByteBuffer.allocate(12)
.order(java.nio.ByteOrder.LITTLE_ENDIAN)
buffer.put('Y'.code.toByte())
buffer.putFloat(pressure)
buffer.put('T'.code.toByte())
buffer.putFloat(temperature)
buffer.put(0x0D)
buffer.put(0x0A)
return buffer.array()
}
/** VM208CC F%05d T%03d E%05d S%c AA + 0D 0AASCII 字段)。 */
private fun vmFrame(
frequencyHz: Double,
temperatureC: Double,
voltageV: Double,
quality: Char,
): ByteArray {
val f = (frequencyHz * 10).roundToInt().coerceIn(0, 99_999)
val t = (temperatureC * 10).roundToInt().coerceIn(0, 999)
val e = (voltageV * 100).roundToInt().coerceIn(0, 99_999)
val text = "CC F%05d T%03d E%05d S%c AA".format(f, t, e, quality)
return text.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
}
private companion object {
const val CONNECT_DELAY = 500L
const val SERVICE_DISCOVER_DELAY = 600L
const val WRITE_DELAY = 120L
const val NOTIFY_INTERVAL = 600L
const val ECHO_COMMAND_LENGTH = 8
const val FAKE_CLEAN_STATUS = 0
const val BASE_PRESSURE = 13.0
const val PRESSURE_AMPLITUDE = 0.8
const val BASE_FREQUENCY = 1234.5
const val FREQUENCY_AMPLITUDE = 25.0
const val BASE_TEMPERATURE = 29.0
const val TEMPERATURE_AMPLITUDE = 0.6
const val BASE_VOLTAGE = 140.0
const val VOLTAGE_AMPLITUDE = 1.5
const val QUALITY = 'G'
}
}

View File

@@ -0,0 +1,32 @@
package com.stec.cmd.core.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 可切换驱动:按演示模式开关把 scan/connect 路由到系统或 Fake 实现M4 演示动线)。
* 纯路由不含连接生命周期(切换开关在连接态下由 UI 拒绝,见采集 ViewModel
*/
class SwitchableBleDriver(
private val system: BleDriver,
private val fake: BleDriver,
) : BleDriver {
private val _demoMode = MutableStateFlow(false)
/** 演示模式开关false=系统蓝牙)。 */
val demoMode: StateFlow<Boolean> = _demoMode.asStateFlow()
fun setDemoMode(enabled: Boolean) {
_demoMode.value = enabled
}
private val active: BleDriver
get() = if (_demoMode.value) fake else system
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = active.scan(types)
override fun connect(device: ScannedDevice): BleConnection = active.connect(device)
}

View File

@@ -0,0 +1,22 @@
package com.stec.cmd.core.ble
import android.Manifest
import android.content.Context
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.flow.Flow
/**
* 系统 BLE 驱动:包装 [BleScanner] 与 [BleSession],机制中立零协议感知。
* 权限由 app 壳申请后调用(扫描 BLUETOOTH_SCAN/定位,连接 BLUETOOTH_CONNECT
*/
class SystemBleDriver(context: Context) : BleDriver {
private val appContext = context.applicationContext
private val scanner = BleScanner(appContext)
private val connector = BleConnector(appContext)
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = scanner.scan(types)
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun connect(device: ScannedDevice): BleConnection = connector.connect(device)
}