This commit is contained in:
阿猫
2026-09-03 12:38:16 +08:00
parent 09669fe4a2
commit e96e92d7ab
68 changed files with 2810 additions and 0 deletions

33
core/ble/build.gradle.kts Normal file
View 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)
}

View 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)
}

View 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
}
}

View 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)
}

View 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) }
}
}
}