From 3cb82169b6a69950efc7597d2392960d3bd3113a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=8C=AB?= Date: Fri, 4 Sep 2026 01:55:48 +0800 Subject: [PATCH] task(efd8bad2-98f6-4ca6-b0e2-06d5f557b43f): Add M4 collect & BLE module (scan/connect/parse/excite/queue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补偿提交:归档自动提交(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) --- .../kotlin/com/stec/cmd/CollectFragment.kt | 7 - .../main/kotlin/com/stec/cmd/FeatureMount.kt | 3 +- .../com/stec/cmd/feature/collect/BleModule.kt | 67 +++ .../cmd/feature/collect/BlePermissions.kt | 43 ++ .../stec/cmd/feature/collect/CollectModels.kt | 81 +++ .../feature/collect/CollectPayloadBuilder.kt | 70 +++ .../feature/collect/CollectSessionManager.kt | 272 +++++++++ .../cmd/feature/collect/CollectViewModel.kt | 279 +++++++++ .../collect/CollectWorkspaceFragment.kt | 381 ++++++++++++ .../cmd/feature/collect/DeviceListAdapter.kt | 62 ++ .../feature/collect/MeasurementPointSource.kt | 52 ++ app/src/main/res/layout/fragment_collect.xml | 540 ++++++++++++++++++ app/src/main/res/layout/item_ble_device.xml | 43 ++ app/src/main/res/values/strings.xml | 67 +++ .../collect/CollectPayloadBuilderTest.kt | 90 +++ .../protocol/vm208/Vm208FrameParserTest.kt | 5 +- .../com/stec/cmd/core/ble/BleConnector.kt | 222 +++++-- .../kotlin/com/stec/cmd/core/ble/BleDriver.kt | 23 + .../com/stec/cmd/core/ble/FakeBleDriver.kt | 196 +++++++ .../stec/cmd/core/ble/SwitchableBleDriver.kt | 32 ++ .../com/stec/cmd/core/ble/SystemBleDriver.kt | 22 + 21 files changed, 2492 insertions(+), 65 deletions(-) delete mode 100644 app/src/main/kotlin/com/stec/cmd/CollectFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/BlePermissions.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/CollectSessionManager.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/DeviceListAdapter.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt create mode 100644 app/src/main/res/layout/fragment_collect.xml create mode 100644 app/src/main/res/layout/item_ble_device.xml create mode 100644 app/src/test/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilderTest.kt create mode 100644 core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt create mode 100644 core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt create mode 100644 core/ble/src/main/kotlin/com/stec/cmd/core/ble/SwitchableBleDriver.kt create mode 100644 core/ble/src/main/kotlin/com/stec/cmd/core/ble/SystemBleDriver.kt diff --git a/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt b/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt deleted file mode 100644 index 5c64630..0000000 --- a/app/src/main/kotlin/com/stec/cmd/CollectFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stec.cmd - -import dagger.hilt.android.AndroidEntryPoint - -/** 采集占位(M4+M5 数据采集,S2/S3 挂载扫描与采集页)。 */ -@AndroidEntryPoint -class CollectFragment : PlaceholderFragment() diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt index 70642b2..3845f9b 100644 --- a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt +++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt @@ -1,5 +1,6 @@ package com.stec.cmd +import com.stec.cmd.feature.collect.CollectWorkspaceFragment import com.stec.cmd.feature.home.ProjectWorkspaceFragment import com.stec.cmd.feature.mine.MineFragment import com.stec.cmd.feature.task.TaskWorkspaceFragment @@ -34,7 +35,7 @@ object FeatureMount { val mounts: List = listOf( MountItem(Tab.HOME, ProjectWorkspaceFragment::class.java, "M2 项目工作台"), MountItem(Tab.TASK, TaskWorkspaceFragment::class.java, "M3 任务管理"), - MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"), + MountItem(Tab.COLLECT, CollectWorkspaceFragment::class.java, "M4+M5 数据采集"), MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"), MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"), ) diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt new file mode 100644 index 0000000..f2a1ad8 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt @@ -0,0 +1,67 @@ +package com.stec.cmd.feature.collect + +import android.content.Context +import com.stec.cmd.core.ble.BleDriver +import com.stec.cmd.core.ble.FakeBleDriver +import com.stec.cmd.core.ble.SystemBleDriver +import com.stec.cmd.core.ble.SwitchableBleDriver +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +/** Hilt 限定符:系统蓝牙实现。 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SystemBleDriverImpl + +/** Hilt 限定符:演示模式实现。 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class FakeBleDriverImpl + +/** + * 采集·蓝牙 Hilt 装配: + * - [BleDriver] 单例实为 [SwitchableBleDriver](演示/系统按开关路由); + * - 限定符定义在 app 层,core:ble 保持零 DI 依赖; + * - 测点源默认绑定本地手动实现(M5 平台测点源交付后在此替换绑定)。 + */ +@Module +@InstallIn(SingletonComponent::class) +object BleProvideModule { + + @Provides + @Singleton + @SystemBleDriverImpl + fun provideSystemDriver(@ApplicationContext context: Context): BleDriver = + SystemBleDriver(context) + + @Provides + @Singleton + @FakeBleDriverImpl + fun provideFakeDriver(): BleDriver = FakeBleDriver() + + @Provides + @Singleton + fun provideSwitchableDriver( + @SystemBleDriverImpl system: BleDriver, + @FakeBleDriverImpl fake: BleDriver, + ): SwitchableBleDriver = SwitchableBleDriver(system, fake) +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class BleBindModule { + + @Binds + @Singleton + abstract fun bindBleDriver(impl: SwitchableBleDriver): BleDriver + + @Binds + @Singleton + abstract fun bindPointSource(impl: ManualPointSource): MeasurementPointSource +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/BlePermissions.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/BlePermissions.kt new file mode 100644 index 0000000..97ee7dd --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/BlePermissions.kt @@ -0,0 +1,43 @@ +package com.stec.cmd.feature.collect + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.ContextCompat + +/** + * 蓝牙运行时权限(M4-01)。 + * + * 分版口径:Android 12(API 31,Build.VERSION_CODES.S)起蓝牙改运行时权限 + * BLUETOOTH_SCAN/CONNECT(SCAN 已声明 neverForLocation,扫描不触发定位义务); + * API 30 及以下为 BLUETOOTH/BLUETOOTH_ADMIN 安装期权限 + 运行时 ACCESS_FINE_LOCATION。 + * 与 Manifest 的 maxSdkVersion="30" 划线一致;功能明细表所写「12L 分版」在工程上 + * 以 API 31 为界(新权限模型生效版本)。 + */ +object BlePermissions { + + /** 当前系统版本需运行时申请的权限组。 */ + fun required(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + /** 运行时权限是否已齐。 */ + fun granted(context: Context): Boolean = required().all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + + /** 应用详情页(拒绝后引导用户手动开启)。 */ + fun settingsIntent(context: Context): Intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", context.packageName, null)) +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt new file mode 100644 index 0000000..0a477f6 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt @@ -0,0 +1,81 @@ +package com.stec.cmd.feature.collect + +import androidx.annotation.StringRes +import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.protocol.ie1000.Ie1000Reading +import com.stec.cmd.protocol.vm208.Vm208Quality +import com.stec.cmd.protocol.vm208.Vm208Reading + +/** + * 单条采集读数归一模型(M4-04 IE-1000 / M4-05 VM208)。 + * UI 实时渲染与 M4-07 入队共用,屏蔽两设备帧差异。 + */ +sealed interface CollectSample { + /** 本机接收时刻(epoch 毫秒)。 */ + val receivedAtMillis: Long + + /** IE-1000:压力 / 温度(℃)。 */ + data class Ie1000( + val pressure: Float, + val temperature: Float, + override val receivedAtMillis: Long, + ) : CollectSample + + /** VM208:频率(Hz)/ 温度(℃)/ 电压(V)/ 元件质量。 */ + data class Vm208( + val frequency: Double, + val temperature: Double, + val voltage: Double, + val quality: Vm208Quality, + override val receivedAtMillis: Long, + ) : CollectSample +} + +fun Ie1000Reading.toSample(): CollectSample.Ie1000 = + CollectSample.Ie1000(pressure, temperature, receivedAtMillis) + +fun Vm208Reading.toSample(): CollectSample.Vm208 = + CollectSample.Vm208(frequency, temperature, voltage, quality, receivedAtMillis) + +/** + * 采集绑定目标(M4-08):任务(M3 数据,可空=不绑定)+ 测点。 + * [pointId] 为平台测点 ID,M5 平台测点源交付前手动测点均为空串占位。 + */ +data class CollectTarget( + val task: TaskItem?, + val pointName: String, + val pointId: String = "", +) + +/** 采集会话状态:Idle → Preparing(连接中)→ Running(就绪采集中)→ Idle。 */ +sealed interface CollectSessionState { + data object Idle : CollectSessionState + data class Preparing( + val device: ScannedDevice, + val target: CollectTarget, + ) : CollectSessionState + + data class Running( + val device: ScannedDevice, + val target: CollectTarget, + ) : CollectSessionState +} + +/** 一次性事件(Snackbar 提示),由 Fragment 收集。 */ +sealed interface CollectEvent { + data class Connected(val deviceName: String) : CollectEvent + + /** [willReconnect] 为 true 时提示将自动重连(M4-09)。 */ + data class Disconnected(val deviceName: String, val willReconnect: Boolean) : CollectEvent + data class Reconnecting(val attempt: Int, val delayMillis: Long) : CollectEvent + + /** VM208 激励命令回显应答(M4-06)。 */ + data class ExcitationAck(val value: Int) : CollectEvent + + /** 一般提示(字符串资源 + 可选格式参数,由 UI 层 getString 渲染)。 */ + data class Message( + @StringRes val textRes: Int, + val args: List = emptyList(), + ) : CollectEvent +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt new file mode 100644 index 0000000..2bb9828 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt @@ -0,0 +1,70 @@ +package com.stec.cmd.feature.collect + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * M4-07 采集数据 payload 组装(纯函数,可 JVM 单测)。 + * + * 接口文档 V4.5「上传本次监测值」(POST /OutWebApi/api/UploadMonitorValue)的 + * MonitorValue 必填五字段为骨架:ProjectID / PointID / Value / MonitorDate / + * MonitoringPlanID;另以 Extra 等扩展键保留完整采集现场(次要量、设备与测点名、 + * 任务编号)。M6 上传通道落地时按平台最终约定精化,扩展键可直接丢弃。 + * + * Value 取主监测量(IE-1000=压力,VM208=频率);PointID 在 M5 平台测点源交付前 + * 为空串占位(手动测点无平台 ID)。 + */ +object CollectPayloadBuilder { + + /** 文档 datetime 口径的监测日期格式。 */ + val MONITOR_DATE_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + fun build( + sample: CollectSample, + target: CollectTarget, + projectId: String?, + deviceType: String, + deviceName: String, + ): String { + val monitorDate = Instant.ofEpochMilli(sample.receivedAtMillis) + .atZone(ZoneId.systemDefault()) + .format(MONITOR_DATE_FORMAT) + val root: JsonObject = buildJsonObject { + put("ProjectID", projectId.orEmpty()) + put("PointID", target.pointId) + put("Value", primaryValue(sample)) + put("MonitorDate", monitorDate) + put("MonitoringPlanID", target.task?.PlanID.orEmpty()) + // —— 扩展现场字段(M6 通道精化前的完整留痕)—— + put("DeviceType", deviceType) + put("DeviceName", deviceName) + put("PointName", target.pointName) + target.task?.TaskCode?.let { put("TaskCode", it) } + put("Extra", extraJson(sample)) + } + return root.toString() + } + + /** 主监测量文本(IE=压力、VM=频率),即 MonitorValue.Value。 */ + fun primaryValue(sample: CollectSample): String = when (sample) { + is CollectSample.Ie1000 -> sample.pressure.toString() + is CollectSample.Vm208 -> sample.frequency.toString() + } + + private fun extraJson(sample: CollectSample): JsonObject = when (sample) { + is CollectSample.Ie1000 -> buildJsonObject { + put("temperature", sample.temperature.toString()) + } + + is CollectSample.Vm208 -> buildJsonObject { + put("temperature", sample.temperature.toString()) + put("voltage", sample.voltage.toString()) + put("quality", sample.quality.code.toString()) + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectSessionManager.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectSessionManager.kt new file mode 100644 index 0000000..ace3379 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectSessionManager.kt @@ -0,0 +1,272 @@ +package com.stec.cmd.feature.collect + +import android.annotation.SuppressLint +import com.stec.cmd.core.ble.BleConnection +import com.stec.cmd.core.ble.BleDriver +import com.stec.cmd.core.ble.BleUuids +import com.stec.cmd.core.ble.DeviceType +import com.stec.cmd.core.ble.GattState +import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.core.database.UploadQueueDao +import com.stec.cmd.core.database.UploadQueueEntity +import com.stec.cmd.core.database.UploadStatus +import com.stec.cmd.protocol.ie1000.Ie1000FrameParser +import com.stec.cmd.protocol.vm208.Vm208CommandEncoder +import com.stec.cmd.protocol.vm208.Vm208FrameParser +import com.stec.cmd.session.ProjectSession +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.min +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * 采集会话编排(M4-03/04/05/06/07/08/09 集中装配): + * + * - 会话状态机 Idle → Preparing → Running → Idle;连接与数据链在单协程内驱动; + * - 每会话新建帧解析器实例(内部字节缓冲不可跨会话复用),停止即丢弃; + * - 解析出的每条读数实时上屏并逐条入 Room 上传队列(status=pending,M6 通道落地 + * 前只入队不上传,离线优先); + * - 断连即发事件提示,会话运行中且自动重连开启时按指数退避(1s 起、×2、30s 封顶) + * 重连;手动停止不重连; + * - VM208 激励命令经当前连接下发,设备原样回显的 ack 在字节入口先行识别。 + * + * ⚠ 驱动层 write 为单挂起桥,激励下发由 UI 单入口串行调用。 + */ +@Singleton +@SuppressLint("MissingPermission") +class CollectSessionManager @Inject constructor( + private val bleDriver: BleDriver, + private val uploadQueueDao: UploadQueueDao, + private val projectSession: ProjectSession, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _session = MutableStateFlow(CollectSessionState.Idle) + + /** 会话状态流(UI 门控渲染)。 */ + val session: StateFlow = _session.asStateFlow() + + private val _gattState = MutableStateFlow(GattState.Disconnected(STATUS_IDLE)) + + /** 当前连接 GATT 四态流(M4-03)。 */ + val gattState: StateFlow = _gattState.asStateFlow() + + private val _latestSample = MutableStateFlow(null) + + /** 最新一条解析读数(M4-04/05 实时展示)。 */ + val latestSample: StateFlow = _latestSample.asStateFlow() + + private val _sampleCount = MutableStateFlow(0) + + /** 本次会话累计解析条数(= 入队条数)。 */ + val sampleCount: StateFlow = _sampleCount.asStateFlow() + + /** 断连提示 / 重连进度 / 激励 ack 一次性事件流。 */ + val events = MutableSharedFlow(extraBufferCapacity = 16) + + private val _autoReconnect = MutableStateFlow(true) + + /** 自动重连开关(M4-09 可关)。 */ + val autoReconnect: StateFlow = _autoReconnect.asStateFlow() + + private var sessionJob: Job? = null + + private var connection: BleConnection? = null + + /** 开始采集:连接 [device] 并绑定 [target](会话非 Idle 时忽略)。 */ + fun start(device: ScannedDevice, target: CollectTarget) { + if (_session.value !is CollectSessionState.Idle) return + sessionJob?.cancel() + _latestSample.value = null + _sampleCount.value = 0 + _session.value = CollectSessionState.Preparing(device, target) + sessionJob = scope.launch { runSession(device, target) } + } + + /** 停止采集并断开(手动断开不触发自动重连,M4-09)。 */ + fun stop() { + _session.value = CollectSessionState.Idle + sessionJob?.cancel() + sessionJob = null + connection?.disconnect() + } + + /** 更新自动重连开关。 */ + fun setAutoReconnect(enabled: Boolean) { + _autoReconnect.value = enabled + } + + /** + * VM208 激励命令下发(M4-06):仅会话运行、设备为 VM208 且连接 Ready 时有效。 + * 应答经 Notify 回显,由 [events] 发 [CollectEvent.ExcitationAck]。 + */ + suspend fun sendExcitation(value: Int): Boolean { + val running = _session.value as? CollectSessionState.Running ?: return false + if (running.device.type != DeviceType.VM208) return false + if (_gattState.value != GattState.Ready) return false + val bytes = Vm208CommandEncoder.encodeExcitation(value) + val conn = connection ?: return false + return conn.write(BleUuids.profileOf(DeviceType.VM208), bytes) + } + + /** + * 会话主循环:连接 → 就绪 → 订阅解析入队;断开后按自动重连策略回到连接步。 + * 手动 stop / 会话 Idle / 取消协程均退出循环。 + */ + private suspend fun runSession(device: ScannedDevice, target: CollectTarget) { + var failedAttempts = 0 + while (currentCoroutineContext().isActive && _session.value !is CollectSessionState.Idle) { + val conn = bleDriver.connect(device) + connection = conn + _gattState.value = GattState.Connecting + try { + conn.awaitReady() + _gattState.value = GattState.Ready + } catch (e: Exception) { + _gattState.value = GattState.Disconnected(STATUS_CONNECT_TIMEOUT) + conn.disconnect() + } + if (_gattState.value != GattState.Ready) { + connection = null + if (!shouldReconnect()) { + events.tryEmit(CollectEvent.Disconnected(device.name, willReconnect = false)) + _session.value = CollectSessionState.Idle + _gattState.value = GattState.Disconnected(STATUS_IDLE) + break + } + failedAttempts++ + val backoff = backoffMillis(failedAttempts) + events.tryEmit(CollectEvent.Reconnecting(failedAttempts, backoff)) + delay(backoff) + continue + } + failedAttempts = 0 + _session.value = CollectSessionState.Running(device, target) + events.tryEmit(CollectEvent.Connected(device.name)) + + val parser = parserOf(device.type) + val profile = BleUuids.profileOf(device.type) + val notifyJob = scope.launch { + try { + conn.notifyBytes(profile).collect { bytes -> + handleBytes(device, target, parser, bytes) + } + } catch (e: Exception) { + // Notify 流异常按断连处理,由下方状态等待收敛 + } + } + // 挂起等待链路断开(真实断连或手动 stop 触发的 disconnect) + conn.state.first { it is GattState.Disconnected } + _gattState.value = conn.state.value + notifyJob.cancel() + connection = null + val willReconnect = + _session.value is CollectSessionState.Running && _autoReconnect.value + events.tryEmit(CollectEvent.Disconnected(device.name, willReconnect)) + if (!willReconnect) { + // 手动停止 / 自动重连已关:会话收敛为 Idle(后者断连即结束采集) + if (_session.value !is CollectSessionState.Idle) { + _session.value = CollectSessionState.Idle + } + break + } + failedAttempts++ + val backoff = backoffMillis(failedAttempts) + events.tryEmit(CollectEvent.Reconnecting(failedAttempts, backoff)) + delay(backoff) + } + } + + /** 自动重连前提:开关开启且会话未被手动停止(Running=采集中断连 / Preparing=首连失败)。 */ + private fun shouldReconnect(): Boolean = + _autoReconnect.value && + (_session.value is CollectSessionState.Running || + _session.value is CollectSessionState.Preparing) + + /** + * 字节入口:VM208 激励 ack 先行识别(协议回显帧非数据帧,不喂入解析器), + * 其余字节交由当会话解析器切帧,读数逐条上屏并入队。 + */ + private fun handleBytes( + device: ScannedDevice, + target: CollectTarget, + parser: Any, + bytes: ByteArray, + ) { + if (device.type == DeviceType.VM208) { + Vm208CommandEncoder.decodeExcitationAck(bytes)?.let { value -> + events.tryEmit(CollectEvent.ExcitationAck(value)) + return + } + } + val samples: List = when (parser) { + is Ie1000FrameParser -> parser.feed(bytes).map { it.toSample() } + is Vm208FrameParser -> parser.feed(bytes).map { it.toSample() } + else -> emptyList() + } + samples.forEach { sample -> + _latestSample.value = sample + _sampleCount.value += 1 + enqueueAsync(sample, device, target) + } + } + + /** M4-07:读数组装 MonitorValue 结构后入 Room 上传队列(pending,离线优先)。 */ + private fun enqueueAsync( + sample: CollectSample, + device: ScannedDevice, + target: CollectTarget, + ) { + scope.launch { + val payload = CollectPayloadBuilder.build( + sample = sample, + target = target, + projectId = projectSession.selectedProject.value?.ID, + deviceType = device.type.name, + deviceName = device.name, + ) + uploadQueueDao.enqueue( + UploadQueueEntity( + deviceType = device.type.name, + deviceId = device.name, + dataType = target.task?.MonitoringType?.takeIf { it.isNotBlank() } + ?: DATA_TYPE_DEFAULT, + payloadJson = payload, + status = UploadStatus.PENDING, + createdAt = System.currentTimeMillis(), + ), + ) + } + } + + private fun backoffMillis(attempt: Int): Long = + min(MAX_BACKOFF_MILLIS, INITIAL_BACKOFF_MILLIS shl (attempt - 1).coerceAtMost(4)) + + private fun parserOf(type: DeviceType): Any = when (type) { + DeviceType.IE1000 -> Ie1000FrameParser() + DeviceType.VM208 -> Vm208FrameParser() + } + + private companion object { + const val INITIAL_BACKOFF_MILLIS = 1_000L + const val MAX_BACKOFF_MILLIS = 30_000L + + /** 停止态/空闲态的 GATT status 占位(非系统错误码)。 */ + const val STATUS_IDLE = 0 + const val STATUS_CONNECT_TIMEOUT = -1 + const val DATA_TYPE_DEFAULT = "蓝牙采集" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt new file mode 100644 index 0000000..3c442d6 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt @@ -0,0 +1,279 @@ +package com.stec.cmd.feature.collect + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.stec.cmd.R +import com.stec.cmd.config.ConfigRepository +import com.stec.cmd.core.ble.BleScanFailed +import com.stec.cmd.core.ble.DeviceType +import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.core.ble.SwitchableBleDriver +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.feature.mine.userMessage +import com.stec.cmd.feature.task.TaskRepository +import com.stec.cmd.session.ProjectSession +import com.stec.cmd.session.SessionRepository +import com.stec.cmd.session.SessionState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 采集工作台(M4-01/02/08 交互编排 + 会话/连接/数据流转发)。 + * + * 门控沿用 M3:SignedIn 且三要素就绪才可见扫描区;会话离开 SignedIn 复位; + * selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7)。 + */ +@HiltViewModel +class CollectViewModel @Inject constructor( + private val switchableDriver: SwitchableBleDriver, + private val sessionManager: CollectSessionManager, + private val taskRepository: TaskRepository, + private val pointSource: MeasurementPointSource, + projectSession: ProjectSession, + sessionRepository: SessionRepository, + private val configRepository: ConfigRepository, +) : ViewModel() { + + /** 会话状态流(Restoring/SignedOut/SignedIn 门控)。 */ + val sessionState = sessionRepository.state + + /** 连接配置快照流(三要素缺失时引导配置)。 */ + val configSnapshot = configRepository.snapshotStateFlow + + /** 当前选中项目。 */ + val selectedProject = projectSession.selectedProject + + // ---- M4-01 权限 ---- + + private val _permissionGranted = MutableStateFlow(null) + + /** null=未询问,true=已授权,false=被拒绝。 */ + val permissionGranted: StateFlow = _permissionGranted.asStateFlow() + + // ---- 演示模式 ---- + + val demoMode = switchableDriver.demoMode + + // ---- M4-02 扫描 ---- + + private val _scanning = MutableStateFlow(false) + val scanning: StateFlow = _scanning.asStateFlow() + + private val _devices = MutableStateFlow>(emptyList()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _selectedDevice = MutableStateFlow(null) + val selectedDevice: StateFlow = _selectedDevice.asStateFlow() + + private var scanJob: Job? = null + + // ---- M4-08 任务与测点 ---- + + private val _tasks = MutableStateFlow>>(UiState.Idle) + val tasks: StateFlow>> = _tasks.asStateFlow() + + private val _selectedTask = MutableStateFlow(null) + val selectedTask: StateFlow = _selectedTask.asStateFlow() + + val points: StateFlow> = pointSource.points + + private val _selectedPoint = MutableStateFlow("") + val selectedPoint: StateFlow = _selectedPoint.asStateFlow() + + // ---- 会话/连接/数据(CollectSessionManager 转发)---- + + val collectSession = sessionManager.session + val gattState = sessionManager.gattState + val latestSample = sessionManager.latestSample + val sampleCount = sessionManager.sampleCount + val autoReconnect = sessionManager.autoReconnect + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + + /** 一次性提示事件(会话事件转发 + 本页交互提示)。 */ + val events: SharedFlow = _events.asSharedFlow() + + init { + viewModelScope.launch { + sessionManager.events.collect { _events.emit(it) } + } + viewModelScope.launch { + sessionState.collect { state -> + if (state is SessionState.SignedIn) { + if (configRepository.snapshotStateFlow.value.isReady && + _tasks.value is UiState.Idle + ) refreshTasks() + } else { + resetSessionUi() + } + } + } + viewModelScope.launch { + configSnapshot.collect { snapshot -> + if (snapshot.isReady && + sessionState.value is SessionState.SignedIn && + _tasks.value is UiState.Idle + ) refreshTasks() + } + } + viewModelScope.launch { + selectedProject.collect { + _tasks.value = UiState.Idle + _selectedTask.value = null + if (it != null && sessionState.value is SessionState.SignedIn) { + refreshTasks() + } + } + } + } + + // ---- 权限 ---- + + /** Fragment 权限申请结果回传;授权即自动进入扫描。 */ + fun onPermissionResult(granted: Boolean) { + _permissionGranted.value = granted + if (granted) startScan() + } + + // ---- 演示模式 ---- + + fun toggleDemoMode(enabled: Boolean) { + if (collectSession.value !is CollectSessionState.Idle) { + _events.tryEmit(CollectEvent.Message(R.string.collect_demo_block_in_session)) + return + } + switchableDriver.setDemoMode(enabled) + _devices.value = emptyList() + _selectedDevice.value = null + _events.tryEmit( + CollectEvent.Message( + if (enabled) R.string.collect_demo_on else R.string.collect_demo_off, + ), + ) + } + + // ---- 扫描 ---- + + fun startScan() { + if (_scanning.value || _permissionGranted.value != true) return + scanJob?.cancel() + _devices.value = emptyList() + _selectedDevice.value = null + scanJob = viewModelScope.launch { + _scanning.value = true + try { + switchableDriver.scan().collect { device -> + _devices.value = upsertDevice(_devices.value, device) + } + } catch (e: BleScanFailed) { + _events.tryEmit( + CollectEvent.Message(R.string.collect_scan_failed_fmt, listOf(e.code)), + ) + } catch (e: SecurityException) { + _events.tryEmit(CollectEvent.Message(R.string.collect_permission_denied_hint)) + } finally { + _scanning.value = false + } + } + } + + fun stopScan() { + scanJob?.cancel() + _scanning.value = false + } + + /** 连接选定设备并停止扫描(M4-03)。 */ + fun selectDevice(device: ScannedDevice) { + _selectedDevice.value = device + stopScan() + } + + // ---- 任务与测点 ---- + + /** 下拉/手动刷新当前项目任务列表(2.7 计划任务)。 */ + fun refreshTasks() { + if (_tasks.value is UiState.Loading) return + viewModelScope.launch { + _tasks.value = UiState.Loading + _tasks.value = try { + UiState.Success(taskRepository.planTasks(selectedProject.value?.ID)) + } catch (e: ApiError) { + UiState.Error(e.userMessage()) + } + } + } + + fun selectTask(task: TaskItem?) { + _selectedTask.value = task + } + + /** 手动录入测点进本地源(历史留痕,M5 平台测点源交付后由其替换)。 */ + fun selectPoint(name: String) { + _selectedPoint.value = name.trim() + } + + // ---- 会话 ---- + + /** 开始采集(M4-08):须已选设备且填写测点;测点录入本地源留痕。 */ + fun startCollect(pointInput: String) { + val device = _selectedDevice.value ?: run { + _events.tryEmit(CollectEvent.Message(R.string.collect_need_device)) + return + } + val point = pointInput.trim() + if (point.isEmpty()) { + _events.tryEmit(CollectEvent.Message(R.string.collect_need_point)) + return + } + selectPoint(point) + viewModelScope.launch { pointSource.record(point) } + sessionManager.start(device, CollectTarget(task = _selectedTask.value, pointName = point)) + } + + fun stopCollect() = sessionManager.stop() + + fun setAutoReconnect(enabled: Boolean) = sessionManager.setAutoReconnect(enabled) + + /** VM208 激励下发(M4-06);输入非法或设备未就绪经事件提示。 */ + fun sendExcitation(rawInput: String) { + val value = rawInput.trim().toIntOrNull() + if (value == null || value !in 0..0xFFFF) { + _events.tryEmit(CollectEvent.Message(R.string.collect_excitation_invalid)) + return + } + viewModelScope.launch { + if (!sessionManager.sendExcitation(value)) { + _events.tryEmit(CollectEvent.Message(R.string.collect_excitation_not_ready)) + } + } + } + + /** 会话中断开/登出时清理本地选择。 */ + private fun resetSessionUi() { + stopScan() + _devices.value = emptyList() + _selectedDevice.value = null + _tasks.value = UiState.Idle + _selectedTask.value = null + if (collectSession.value !is CollectSessionState.Idle) stopCollect() + } + + private fun upsertDevice( + current: List, + incoming: ScannedDevice, + ): List { + val index = current.indexOfFirst { it.address == incoming.address } + return if (index < 0) current + incoming + else current.toMutableList().also { it[index] = incoming } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt new file mode 100644 index 0000000..2f0ca67 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt @@ -0,0 +1,381 @@ +package com.stec.cmd.feature.collect + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.stec.cmd.R +import com.stec.cmd.core.ble.GattState +import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.core.common.UiState +import com.stec.cmd.databinding.FragmentCollectBinding +import com.stec.cmd.protocol.vm208.Vm208Quality +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +/** + * 采集工作台(M4 全动线挂载页)。 + * + * 渲染状态机:未授权(权限卡)→ 已授权(扫描区可见)→ 选中设备(连接行可见)→ + * 采集中(会话卡切换为停止、实时数据卡与激励卡按设备类型显隐)。 + * 演示模式开关驱动 FakeBleDriver,无蓝牙硬件环境亦可完整走线。 + */ +@AndroidEntryPoint +class CollectWorkspaceFragment : Fragment() { + + private var _binding: FragmentCollectBinding? = null + private val binding get() = checkNotNull(_binding) + + private val viewModel: CollectViewModel by viewModels() + + private val deviceAdapter by lazy { DeviceListAdapter(viewModel::selectDevice) } + + /** M4-01 运行时权限申请(按系统版本分版,见 BlePermissions.required)。 */ + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + viewModel.onPermissionResult(result.values.all { it }) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentCollectBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.recyclerDevices.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerDevices.adapter = deviceAdapter + + binding.btnGrantPermission.setOnClickListener { + permissionLauncher.launch(BlePermissions.required()) + } + binding.btnOpenSettings.setOnClickListener { + startActivity(BlePermissions.settingsIntent(requireContext())) + } + binding.switchDemo.setOnCheckedChangeListener { _, checked -> + if (binding.switchDemo.isPressed) viewModel.toggleDemoMode(checked) + } + binding.btnScan.setOnClickListener { + if (viewModel.scanning.value) viewModel.stopScan() else viewModel.startScan() + } + binding.btnDisconnect.setOnClickListener { viewModel.stopCollect() } + binding.switchAutoReconnect.setOnCheckedChangeListener { _, checked -> + if (binding.switchAutoReconnect.isPressed) viewModel.setAutoReconnect(checked) + } + binding.btnPickTask.setOnClickListener { showTaskPicker() } + binding.btnStartCollect.setOnClickListener { + viewModel.startCollect(binding.etPoint.text?.toString().orEmpty()) + } + binding.btnStopCollect.setOnClickListener { viewModel.stopCollect() } + binding.btnSendExcitation.setOnClickListener { + viewModel.sendExcitation(binding.etExcitation.text?.toString().orEmpty()) + } + + // 权限初始化:已授权直接进扫描态 + if (BlePermissions.granted(requireContext())) { + viewModel.onPermissionResult(true) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.events.collect { showEvent(it) } } + launch { viewModel.sessionState.collect { render() } } + launch { viewModel.configSnapshot.collect { render() } } + launch { viewModel.selectedProject.collect { render() } } + launch { viewModel.permissionGranted.collect { render() } } + launch { viewModel.demoMode.collect { render() } } + launch { viewModel.scanning.collect { render() } } + launch { viewModel.devices.collect { renderDevices(it) } } + launch { viewModel.selectedDevice.collect { render() } } + launch { viewModel.tasks.collect { render() } } + launch { viewModel.selectedTask.collect { render() } } + launch { viewModel.selectedPoint.collect { render() } } + launch { viewModel.collectSession.collect { render() } } + launch { viewModel.gattState.collect { render() } } + launch { viewModel.latestSample.collect { renderSample(it) } } + launch { viewModel.sampleCount.collect { render() } } + launch { viewModel.autoReconnect.collect { render() } } + } + } + } + + /** 任一流变化即整体重渲(读 ViewModel 当前值,与任务页同款避免组合流样板)。 */ + private fun render() { + val session = viewModel.sessionState.value + val config = viewModel.configSnapshot.value + + // ---- 门控:会话/配置/项目(对齐任务页动线)---- + when { + session is com.stec.cmd.session.SessionState.Restoring -> { + showGate(GateAction.NONE) + return + } + + session is com.stec.cmd.session.SessionState.SignedOut -> { + showGate(GateAction.LOGIN) + return + } + + !config.isReady -> { + showGate(GateAction.CONFIG) + return + } + + viewModel.selectedProject.value == null -> { + showGate(GateAction.PROJECT) + return + } + + else -> binding.cardGate.isVisible = false + } + + // ---- M4-01 权限段 ---- + val granted = viewModel.permissionGranted.value + binding.cardPermission.isVisible = granted != true + binding.tvPermissionDenied.isVisible = granted == false + binding.btnOpenSettings.isVisible = granted == false + binding.btnGrantPermission.isVisible = granted != false + + val inSession = viewModel.collectSession.value !is CollectSessionState.Idle + binding.cardConnect.isVisible = granted == true + binding.cardSession.isVisible = granted == true + binding.cardDemo.isVisible = granted == true + + // ---- M4-02 扫描 ---- + val scanning = viewModel.scanning.value + binding.btnScan.text = + getString(if (scanning) R.string.collect_scan_stop else R.string.collect_scan_start) + binding.progressScan.isVisible = scanning + binding.tvScanEmpty.isVisible = !scanning && viewModel.devices.value.isEmpty() + + // ---- 已连接行 / 自动重连开关 ---- + val selected = viewModel.selectedDevice.value + val running = viewModel.collectSession.value + binding.rowConnected.isVisible = inSession + if (inSession) { + val device = (running as? CollectSessionState.Running)?.device + ?: (running as? CollectSessionState.Preparing)?.device + ?: selected + binding.tvConnectedDevice.text = device?.name.orEmpty() + binding.tvGattState.text = stateText(viewModel.gattState.value) + binding.btnDisconnect.isVisible = false // 会话内以「停止采集」承载断开 + } + binding.switchAutoReconnect.isVisible = inSession + binding.switchAutoReconnect.isChecked = viewModel.autoReconnect.value + + // ---- M4-08 会话卡 ---- + val idle = running is CollectSessionState.Idle + binding.btnPickTask.isVisible = idle + binding.tilPoint.isVisible = idle + binding.btnStartCollect.isVisible = idle + binding.btnStopCollect.isVisible = !idle + if (idle) { + val task = viewModel.selectedTask.value + binding.btnPickTask.text = task?.let { taskTitle(it) } + ?: getString(R.string.collect_pick_task) + binding.btnStartCollect.isEnabled = selected != null + if (binding.etPoint.text.isNullOrBlank() && viewModel.selectedPoint.value.isNotEmpty()) { + binding.etPoint.setText(viewModel.selectedPoint.value) + } + } else { + val target = (running as? CollectSessionState.Running)?.target + ?: (running as? CollectSessionState.Preparing)?.target + binding.tvBoundTarget.isVisible = true + binding.tvBoundTarget.text = buildString { + append(getString(R.string.collect_session_title)) + append(":") + append(target?.task?.let(::taskTitle) ?: getString(R.string.collect_task_not_bound)) + append(" · ") + append(target?.pointName.orEmpty()) + } + } + + // ---- 实时数据卡:设备类型驱动显隐 ---- + val sessionDevice = (running as? CollectSessionState.Running)?.device + ?: (running as? CollectSessionState.Preparing)?.device + ?: selected + val isVm = sessionDevice?.type == com.stec.cmd.core.ble.DeviceType.VM208 + val dataVisible = !idle + binding.cardData.isVisible = dataVisible + binding.groupPressure.isVisible = !isVm + binding.groupTemperature.isVisible = true + binding.groupVmRow1.isVisible = isVm + binding.groupQuality.isVisible = isVm + binding.tvSampleCount.text = getString( + R.string.collect_sample_count_fmt, + viewModel.sampleCount.value, + ) + + // ---- M4-06 激励卡:仅 VM208 会话 ---- + binding.cardExcitation.isVisible = dataVisible && isVm + binding.btnSendExcitation.isEnabled = viewModel.gattState.value == GattState.Ready + } + + private fun renderDevices(devices: List) { + deviceAdapter.submitList(devices) + binding.tvScanEmpty.isVisible = + !viewModel.scanning.value && devices.isEmpty() + render() + } + + private fun renderSample(sample: CollectSample?) { + if (sample == null) { + binding.tvWaitingData.isVisible = true + return + } + binding.tvWaitingData.isVisible = false + when (sample) { + is CollectSample.Ie1000 -> { + binding.tvPressure.text = sample.pressure.toString() + binding.tvTemperature.text = sample.temperature.toString() + } + + is CollectSample.Vm208 -> { + binding.tvFrequency.text = sample.frequency.toString() + binding.tvTemperature.text = sample.temperature.toString() + binding.tvVoltage.text = sample.voltage.toString() + binding.tvQuality.text = qualityText(sample.quality) + } + } + render() + } + + private fun stateText(state: GattState): String = when (state) { + is GattState.Connecting -> getString(R.string.collect_state_connecting) + is GattState.Connected -> getString(R.string.collect_state_connected) + is GattState.Ready -> getString(R.string.collect_state_ready) + is GattState.Disconnected -> getString(R.string.collect_state_disconnected) + } + + private fun qualityText(quality: Vm208Quality): String = getString( + when (quality) { + Vm208Quality.GOOD -> R.string.collect_quality_good + Vm208Quality.FAIR -> R.string.collect_quality_fair + Vm208Quality.NONE -> R.string.collect_quality_none + Vm208Quality.UNKNOWN -> R.string.collect_quality_unknown + }, + ) + + /** M4-08 任务选择对话框(2.7 计划任务 + 不绑定选项)。 */ + private fun showTaskPicker() { + val state = viewModel.tasks.value + if (state is UiState.Error) { + viewModel.refreshTasks() + return + } + val tasks = (state as? UiState.Success)?.data.orEmpty() + val titles = tasks.map(::taskTitle) + getString(R.string.collect_task_none) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.collect_pick_task) + .setItems(titles.toTypedArray()) { _, which -> + viewModel.selectTask(tasks.getOrNull(which)) + } + .show() + } + + private fun taskTitle(task: TaskItem): String = getString( + R.string.collect_task_fmt, + task.TaskCode?.takeIf { it.isNotBlank() } ?: task.SimpleName.orEmpty(), + task.MonitoringType.orEmpty(), + task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(), + ) + + private fun showEvent(event: CollectEvent) { + val text = when (event) { + is CollectEvent.Connected -> + getString(R.string.collect_event_connected_fmt, event.deviceName) + is CollectEvent.Disconnected -> + getString( + if (event.willReconnect) R.string.collect_event_disconnected_reconnect + else R.string.collect_event_disconnected, + ) + is CollectEvent.Reconnecting -> getString( + R.string.collect_event_reconnecting_fmt, + event.attempt, + event.delayMillis / 1000, + ) + is CollectEvent.ExcitationAck -> + getString(R.string.collect_excitation_ack_fmt, event.value) + is CollectEvent.Message -> getString(event.textRes, *event.args.toTypedArray()) + } + showSnackbar(text) + } + + private fun showSnackbar(text: String) { + Snackbar.make(binding.root, text, Snackbar.LENGTH_SHORT).show() + } + + /** 门控卡:未登录/缺配置/未选项目时给出唯一可见引导。 */ + private fun showGate(action: GateAction) { + binding.cardGate.isVisible = action != GateAction.NONE + binding.cardPermission.isVisible = false + binding.cardDemo.isVisible = false + binding.cardConnect.isVisible = false + binding.cardSession.isVisible = false + binding.cardData.isVisible = false + binding.cardExcitation.isVisible = false + if (action == GateAction.NONE) return + binding.tvGateHint.setText( + when (action) { + GateAction.LOGIN -> R.string.collect_gate_signed_out + GateAction.CONFIG -> R.string.collect_gate_no_config + else -> R.string.collect_gate_no_project + }, + ) + binding.btnGateAction.setText( + when (action) { + GateAction.LOGIN -> R.string.collect_gate_go_login + GateAction.CONFIG -> R.string.collect_gate_go_config + else -> R.string.collect_gate_go_home + }, + ) + binding.btnGateAction.setOnClickListener { + when (action) { + GateAction.LOGIN -> openOnTop(com.stec.cmd.feature.mine.LoginFragment()) + GateAction.CONFIG -> openOnTop(com.stec.cmd.feature.mine.ConfigFragment()) + else -> requireActivity() + .findViewById(R.id.bottom_nav) + .selectedItemId = R.id.nav_home + } + } + } + + /** 子页跳转:add + hide + addToBackStack(壳层同款模式)。 */ + private fun openOnTop(fragment: Fragment) { + parentFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container, fragment, "collect_gate_sub") + hide(this@CollectWorkspaceFragment) + addToBackStack(null) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** 门控动作类型。 */ + private enum class GateAction { + NONE, + LOGIN, + CONFIG, + PROJECT, + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/DeviceListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/DeviceListAdapter.kt new file mode 100644 index 0000000..e9e99ba --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/DeviceListAdapter.kt @@ -0,0 +1,62 @@ +package com.stec.cmd.feature.collect + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.stec.cmd.R +import com.stec.cmd.core.ble.DeviceType +import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.databinding.ItemBleDeviceBinding + +/** + * 扫描结果列表(M4-02):设备名 + 类型 + 信号强度 + 连接动作。 + * 同地址覆盖发射由 ViewModel upsert 收敛,DiffUtil 仅刷新变化行。 + */ +class DeviceListAdapter( + private val onConnect: (ScannedDevice) -> Unit, +) : ListAdapter(DIFF) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceViewHolder = + DeviceViewHolder( + ItemBleDeviceBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + + override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) = + holder.bind(getItem(position)) + + inner class DeviceViewHolder( + private val binding: ItemBleDeviceBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(device: ScannedDevice) { + binding.tvDeviceName.text = device.name + binding.tvDeviceMeta.text = buildString { + append( + binding.root.context.getString( + when (device.type) { + DeviceType.IE1000 -> R.string.collect_device_type_ie + DeviceType.VM208 -> R.string.collect_device_type_vm + }, + ), + ) + append(" · ") + append( + binding.root.context.getString(R.string.collect_device_rssi_fmt, device.rssi), + ) + } + binding.btnDeviceConnect.setOnClickListener { onConnect(device) } + } + } + + private companion object { + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ScannedDevice, newItem: ScannedDevice) = + oldItem.address == newItem.address + + override fun areContentsTheSame(oldItem: ScannedDevice, newItem: ScannedDevice) = + oldItem == newItem + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt new file mode 100644 index 0000000..f2d38e8 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt @@ -0,0 +1,52 @@ +package com.stec.cmd.feature.collect + +import com.stec.cmd.core.network.api.TaskItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** 测点候选:[id] 为平台测点 ID(M5 交付前手动测点为空串)。 */ +data class MeasurementPoint( + val id: String, + val name: String, +) + +/** + * 测点源抽象(M4-08 预留接口)。 + * + * M4 交付 [ManualPointSource] 本地手动源;M5 子任务回填真实平台测点源 + * (按任务测组拉取 GetPointBySurveyGroup)后经 Hilt 替换绑定,采集会话零改动。 + */ +interface MeasurementPointSource { + + /** 可用测点流(UI 下拉/历史展示;M5 实现按任务测组实时刷新)。 */ + val points: StateFlow> + + /** 拉取 [task] 测组可用测点;task 为 null 时返回本地手动测点。 */ + suspend fun pointsOf(task: TaskItem?): List + + /** 手动录入测点;空名/重复返回既有项或 null。 */ + suspend fun record(name: String): MeasurementPoint? +} + +/** 本地手动测点源(会话内存态,应用进程内保留历史录入)。 */ +@Singleton +class ManualPointSource @Inject constructor() : MeasurementPointSource { + + private val manual = MutableStateFlow>(emptyList()) + + override val points: StateFlow> = manual.asStateFlow() + + override suspend fun pointsOf(task: TaskItem?): List = manual.value + + override suspend fun record(name: String): MeasurementPoint? { + val trimmed = name.trim() + if (trimmed.isEmpty()) return null + manual.value.firstOrNull { it.name == trimmed }?.let { return it } + val point = MeasurementPoint(id = "", name = trimmed) + manual.value = listOf(point) + manual.value + return point + } +} diff --git a/app/src/main/res/layout/fragment_collect.xml b/app/src/main/res/layout/fragment_collect.xml new file mode 100644 index 0000000..8f2d841 --- /dev/null +++ b/app/src/main/res/layout/fragment_collect.xml @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_ble_device.xml b/app/src/main/res/layout/item_ble_device.xml new file mode 100644 index 0000000..5273de2 --- /dev/null +++ b/app/src/main/res/layout/item_ble_device.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a17e87..d64edcb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,71 @@ 确认新密码 提交修改 密码已修改,请使用新密码重新登录 + + 登录后即可扫描采集仪并采集数据 + 请先在「我的」中配置服务器与密钥 + 请先到首页选择当前项目,采集数据将绑定该项目 + 去登录 + 去配置 + 去选择项目 + + + 需要蓝牙权限 + 设备扫描与数据采集依赖蓝牙权限(Android 12 及以下还需定位权限,仅用于扫描附近采集仪,不上传位置)。 + 授予蓝牙权限 + 权限被拒绝,无法扫描设备;可到系统设置中手动开启。 + 去系统设置 + 演示模式(无蓝牙硬件时灌入样例数据) + 演示模式已开启,扫描到的是演示设备 + 演示模式已关闭 + 采集中不能切换演示模式,请先停止采集 + 开始扫描 + 停止扫描 + 未发现设备:请确认采集仪已开机并靠近手机 + 振弦读数仪 + 综合采集仪 + %1$d dBm + 连接 + 当前设备 + 未连接 + 连接中… + 已连接·发现服务 + 已就绪 + 断开 + 自动重连(断开后指数退避) + 设备连接 + 采集会话 + 选择任务 + 不绑定任务 + %1$s · %2$s · %3$s + 测点 + 输入或选择测点名(M5 平台测点源待接入) + 开始采集 + 停止采集 + 请先选择设备并连接 + 请先填写测点名 + 已采集 %1$d 条(离线暂存,M6 上传) + 实时数据 + 压力 + 温度 + 频率 + 电压 + 元件质量 + 良好 + 一般 + 无元件 + 未知 + 等待数据… + VM208 激励下发 + 激励值(0–65535) + 下发 + 激励已设置:%1$d + 请输入 0–65535 范围内的整数 + 设备未就绪,无法下发 + 已连接 %1$s + 连接已断开 + 连接断开,将自动重连 + 第 %1$d 次重连(约 %2$d 秒后重试) + 扫描失败(code=%1$d) + 未绑定任务 diff --git a/app/src/test/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilderTest.kt b/app/src/test/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilderTest.kt new file mode 100644 index 0000000..f990f1d --- /dev/null +++ b/app/src/test/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilderTest.kt @@ -0,0 +1,90 @@ +package com.stec.cmd.feature.collect + +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.protocol.vm208.Vm208Quality +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** M4-07 payload 组装:MonitorValue 五字段骨架 + 扩展现场字段(纯 JVM)。 */ +class CollectPayloadBuilderTest { + + private val receivedAt = Instant.parse("2026-09-04T02:30:05Z").toEpochMilli() + + private fun target(task: TaskItem? = sampleTask()) = + CollectTarget(task = task, pointName = "JC-01", pointId = "") + + private fun sampleTask() = TaskItem( + SimpleName = "测试项目", + TaskCode = "PRJ-001-01-A", + ProjectID = "proj-1", + PlanID = "plan-9", + MonitoringType = "轴力", + ) + + @Test + fun `IE1000 读数组装出 MonitorValue 骨架字段`() { + val json = CollectPayloadBuilder.build( + sample = CollectSample.Ie1000(pressure = 13.04f, temperature = 29.0f, receivedAtMillis = receivedAt), + target = target(), + projectId = "proj-1", + deviceType = "IE1000", + deviceName = "IE-1000", + ) + + assertTrue(json.contains("\"ProjectID\":\"proj-1\"")) + assertTrue(json.contains("\"PointID\":\"\"")) + assertTrue(json.contains("\"Value\":\"13.04\"")) + assertTrue(json.contains("\"MonitoringPlanID\":\"plan-9\"")) + // MonitorDate 为本机时区的 yyyy-MM-dd HH:mm:ss + val expected = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(receivedAt)) + assertTrue("MonitorDate 应包含 $expected", json.contains(expected)) + } + + @Test + fun `VM208 主监测量为频率且 Extra 携带温度电压质量`() { + val json = CollectPayloadBuilder.build( + sample = CollectSample.Vm208( + frequency = 1234.5, + temperature = 33.4, + voltage = 140.0, + quality = Vm208Quality.GOOD, + receivedAtMillis = receivedAt, + ), + target = target(task = null), + projectId = null, + deviceType = "VM208", + deviceName = "VM208 200001", + ) + + assertEquals("1234.5", CollectPayloadBuilder.primaryValue( + CollectSample.Vm208(1234.5, 33.4, 140.0, Vm208Quality.GOOD, receivedAt), + )) + assertTrue(json.contains("\"Value\":\"1234.5\"")) + assertTrue(json.contains("\"ProjectID\":\"\"")) + assertTrue(json.contains("\"temperature\":\"33.4\"")) + assertTrue(json.contains("\"voltage\":\"140.0\"")) + assertTrue(json.contains("\"quality\":\"G\"")) + assertFalse(json.contains("TaskCode")) // 未绑定任务不带任务编号 + } + + @Test + fun `未绑定任务时 MonitoringPlanID 为空且保留点名`() { + val json = CollectPayloadBuilder.build( + sample = CollectSample.Ie1000(pressure = 1.0f, temperature = 2.0f, receivedAtMillis = receivedAt), + target = target(task = null), + projectId = "proj-1", + deviceType = "IE1000", + deviceName = "IE-1000", + ) + + assertTrue(json.contains("\"MonitoringPlanID\":\"\"")) + assertTrue(json.contains("\"PointName\":\"JC-01\"")) + assertTrue(json.contains("\"DeviceName\":\"IE-1000\"")) + } +} diff --git a/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt index 77359b3..c038884 100644 --- a/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt +++ b/ble-protocol/src/test/kotlin/com/stec/cmd/protocol/vm208/Vm208FrameParserTest.kt @@ -11,7 +11,8 @@ class Vm208FrameParserTest { /** * 协议示例报文:CC F12345 T334 E14039 SG AA 0D 0A - * → 频率 1234.5 Hz、温度 33.4 ℃、电压 140 V、质量 GOOD。 + * → 频率 1234.5 Hz、温度 33.4 ℃、电压 140.39 V、质量 GOOD。 + * (原文档「14039/100 = 140V」为算术笔误,按公式 14039/100 = 140.39 断言。) */ private fun sampleFrame(): ByteArray = "CC F12345 T334 E14039 SG AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A) @@ -23,7 +24,7 @@ class Vm208FrameParserTest { assertEquals(1, readings.size) assertEquals(1234.5, readings[0].frequency, 1e-9) assertEquals(33.4, readings[0].temperature, 1e-9) - assertEquals(140.0, readings[0].voltage, 1e-9) + assertEquals(140.39, readings[0].voltage, 1e-9) assertEquals(Vm208Quality.GOOD, readings[0].quality) } diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt index de476b3..6bee3b2 100644 --- a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt +++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt @@ -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 + + /** 挂起等待连接就绪(服务发现成功);失败/超时抛异常。 */ + suspend fun awaitReady(timeoutMillis: Long = DEFAULT_TIMEOUT) + + /** + * 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。 + * 帧定界与解析由上层(ble-protocol)负责,本流按 Notify 回调原样发射。 + */ + fun notifyBytes(profile: BleUuids.Profile): Flow + + /** 写特征(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.Connecting) - /** 连接状态流;Disconnected 后会话即终结。 */ - val state: StateFlow = _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 = _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 { + private val notifyBus = MutableSharedFlow(extraBufferCapacity = 64) + + /** MTU 协商挂起桥:onMtuChanged 一次性回填。 */ + private var mtuRequest: CompletableDeferred? = 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 { 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() 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, + ) { + 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() + 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 + var completeWith: CompletableDeferred? = 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) { diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt new file mode 100644 index 0000000..70d628c --- /dev/null +++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt @@ -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.entries.toSet()): Flow + + /** 建立连接(异步:状态从 [GattState.Connecting] 起流)。 */ + fun connect(device: ScannedDevice): BleConnection +} diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt new file mode 100644 index 0000000..57909f4 --- /dev/null +++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt @@ -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): Flow = 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.Connecting) + override val state: StateFlow = _state.asStateFlow() + + /** 写入回显总线(VM208 激励 ack)。 */ + private val echoBus = MutableSharedFlow(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 = 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' + } +} diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SwitchableBleDriver.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SwitchableBleDriver.kt new file mode 100644 index 0000000..2f1b593 --- /dev/null +++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SwitchableBleDriver.kt @@ -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 = _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): Flow = active.scan(types) + + override fun connect(device: ScannedDevice): BleConnection = active.connect(device) +} diff --git a/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SystemBleDriver.kt b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SystemBleDriver.kt new file mode 100644 index 0000000..370704f --- /dev/null +++ b/core/ble/src/main/kotlin/com/stec/cmd/core/ble/SystemBleDriver.kt @@ -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): Flow = scanner.scan(types) + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun connect(device: ScannedDevice): BleConnection = connector.connect(device) +}