*
This commit is contained in:
33
core/ble/build.gradle.kts
Normal file
33
core/ble/build.gradle.kts
Normal file
@@ -0,0 +1,33 @@
|
||||
// core:ble 蓝牙层:设备扫描(名称前缀过滤)+ GATT 连接封装骨架
|
||||
// 权限申请由 app 壳负责(Android 12L 分版见 app 模块),本模块假定权限已授予
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.ble"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
191
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
Normal file
191
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
|
||||
sealed interface GattState {
|
||||
data object Connecting : GattState
|
||||
data object Connected : GattState
|
||||
data object ServicesDiscovered : GattState
|
||||
data class Disconnected(val status: Int) : GattState
|
||||
}
|
||||
|
||||
/**
|
||||
* 单设备 GATT 连接会话骨架。
|
||||
*
|
||||
* 用法(S2/S3 业务侧):
|
||||
* ```
|
||||
* val session = connector.connect(scanned)
|
||||
* session.state.filter { it is GattState.ServicesDiscovered }.first()
|
||||
* session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
|
||||
* val readings = parser.feed(bytes)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleSession internal constructor(
|
||||
context: Context,
|
||||
address: String,
|
||||
) {
|
||||
private val bluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
private val gatt: BluetoothGatt? =
|
||||
bluetoothManager.adapter?.getRemoteDevice(address)?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
it.connectGatt(context, false, callback, BluetoothGatt.TRANSPORT_LE)
|
||||
} else {
|
||||
it.connectGatt(context, false, callback)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
|
||||
* 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
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 管理)
|
||||
awaitClose {
|
||||
runCatching {
|
||||
gatt.setCharacteristicNotification(characteristic, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 写特征(VM208 激励命令下发)。返回是否写入成功。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): 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)
|
||||
}
|
||||
return withTimeout(timeoutMillis) { done.await() }
|
||||
}
|
||||
|
||||
/** 主动断开并释放 GATT 资源。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
_state.value = GattState.Connected
|
||||
gatt.discoverServices()
|
||||
}
|
||||
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
_state.value = GattState.Disconnected(status)
|
||||
gatt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
_state.value = GattState.ServicesDiscovered
|
||||
} else {
|
||||
_state.value = GattState.Disconnected(status)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
/** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
|
||||
private val writeListener = object {
|
||||
lateinit var completeWith: CompletableDeferred<Boolean>
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_TIMEOUT = 10_000L
|
||||
|
||||
/** Client Characteristic Config 描述符(Notify 开关)。 */
|
||||
val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
|
||||
"00002902-0000-1000-8000-00805f9b34fb",
|
||||
)
|
||||
val ENABLE_NOTIFICATION_VALUE = byteArrayOf(0x01, 0x00)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GATT 连接入口:按扫描结果建立会话。
|
||||
* S0 骨架不引 Hilt,由 app/S3 按需构造或包装。
|
||||
*/
|
||||
class BleConnector(private val context: Context) {
|
||||
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun connect(scanned: ScannedDevice): BleSession = BleSession(context, scanned.address)
|
||||
}
|
||||
86
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
Normal file
86
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/** 一次扫描命中:已按名称前缀过滤为目标设备。 */
|
||||
data class ScannedDevice(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val type: DeviceType,
|
||||
)
|
||||
|
||||
/** 扫描失败(硬件不可用/扫描过频等),code 为 ScanCallback.SCAN_FAILED_* 常量。 */
|
||||
class BleScanFailed(val code: Int) : Exception("BLE 扫描失败: code=$code")
|
||||
|
||||
/**
|
||||
* BLE 扫描器:按设备名前缀过滤 IE-1000 / VM208。
|
||||
*
|
||||
* 前缀过滤在内存中做(广播名偶有截断,[ScanFilter.setDeviceNamePrefix]
|
||||
* 在部分机型不可靠),全量回调后按 [DeviceType.fromDeviceName] 筛选。
|
||||
* 权限由 app 壳申请(12L+ 为 BLUETOOTH_SCAN/CONNECT,旧版为 SCAN + 定位)。
|
||||
*/
|
||||
class BleScanner(context: Context) {
|
||||
|
||||
private val bluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
/**
|
||||
* 开始扫描,命中流自动去重(同地址只保留最近一次)。
|
||||
* 收集端取消或调用 [close] 时停止扫描。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
|
||||
fun scan(
|
||||
types: Set<DeviceType> = DeviceType.entries.toSet(),
|
||||
): Flow<ScannedDevice> {
|
||||
val scanner = bluetoothManager.adapter?.bluetoothLeScanner
|
||||
?: throw BleScanFailed(UNAVAILABLE_ADAPTER)
|
||||
return callbackFlow {
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
val callback = object : ScanCallback() {
|
||||
private val seen = HashMap<String, ScannedDevice>()
|
||||
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.scanRecord?.deviceName ?: result.device?.name
|
||||
val type = DeviceType.fromDeviceName(name) ?: return
|
||||
if (type !in types) return
|
||||
val device = ScannedDevice(
|
||||
name = name,
|
||||
address = result.device.address,
|
||||
rssi = result.rssi,
|
||||
type = type,
|
||||
)
|
||||
// 同地址覆盖发射:UI 层按 address 收敛列表
|
||||
seen[device.address] = device
|
||||
trySend(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
close(BleScanFailed(errorCode))
|
||||
}
|
||||
}
|
||||
scanner.startScan(null, settings, callback)
|
||||
awaitClose {
|
||||
runCatching { scanner.stopScan(callback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNAVAILABLE_ADAPTER = -100
|
||||
}
|
||||
}
|
||||
42
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
Normal file
42
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 两类设备的 GATT UUID 汇总(协议原文 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* ⚠ VM208 服务 UUID 原文档仅 31 位十六进制(`0000fee2000010a0800000602f9b3698`),
|
||||
* 不足标准 128 位(32 位)。此处按末位补 0 占位:
|
||||
* **S2 真机联调时须用 nRF Connect 实测校准**,确认后再更新本文件。
|
||||
*/
|
||||
object BleUuids {
|
||||
|
||||
/** 单设备的 GATT 档案:服务 + 写特征 + Notify 特征。 */
|
||||
data class Profile(
|
||||
val service: UUID,
|
||||
val writeCharacteristic: UUID,
|
||||
val notifyCharacteristic: UUID,
|
||||
)
|
||||
|
||||
/** IE-1000 振弦式读数仪。 */
|
||||
val IE1000: Profile = Profile(
|
||||
service = uuid("0000fee6-0000-2000-6000-006012345678"),
|
||||
writeCharacteristic = uuid("FE000000-0000-0000-0000-000000000580"),
|
||||
notifyCharacteristic = uuid("FE000000-0000-0000-0000-000000000581"),
|
||||
)
|
||||
|
||||
/** VM208 综合采集仪(服务 UUID 见类注释⚠)。 */
|
||||
val VM208: Profile = Profile(
|
||||
service = uuid("0000fee2-0000-10a0-8000-0602f9b36980"),
|
||||
writeCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129700"),
|
||||
notifyCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129701"),
|
||||
)
|
||||
|
||||
/** 按设备类型取档案。 */
|
||||
fun profileOf(type: DeviceType): Profile = when (type) {
|
||||
DeviceType.IE1000 -> IE1000
|
||||
DeviceType.VM208 -> VM208
|
||||
}
|
||||
|
||||
private fun uuid(value: String): UUID = UUID.fromString(value)
|
||||
}
|
||||
22
core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
Normal file
22
core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
/**
|
||||
* 平台支持的采集设备类型(协议原文见 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* 蓝牙名以**前缀**匹配:
|
||||
* - IE-1000 振弦式读数仪,广播名 `IE-1000`;
|
||||
* - VM208 综合采集仪,广播名 `VM208 xxxxxx`(带序列号后缀)。
|
||||
*/
|
||||
enum class DeviceType(val namePrefix: String) {
|
||||
IE1000("IE-1000"),
|
||||
VM208("VM208");
|
||||
|
||||
companion object {
|
||||
|
||||
/** 按广播名前缀识别设备类型;无法识别返回 null(外场杂散设备直接忽略)。 */
|
||||
fun fromDeviceName(name: String?): DeviceType? {
|
||||
if (name.isNullOrEmpty()) return null
|
||||
return entries.firstOrNull { name.startsWith(it.namePrefix) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user