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:
@@ -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) {
|
||||
|
||||
|
||||
23
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt
Normal file
23
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt
Normal 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
|
||||
}
|
||||
196
core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt
Normal file
196
core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt
Normal 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 200001),RSSI 带抖动;
|
||||
* - 连接:约 0.5s Connected、再 0.6s Ready,模拟真实节奏;
|
||||
* - Notify:Ready 后每约 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()
|
||||
}
|
||||
|
||||
/** VM208:CC F%05d T%03d E%05d S%c AA + 0D 0A(ASCII 字段)。 */
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user