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

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

View File

@@ -1,7 +0,0 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 采集占位M4+M5 数据采集S2/S3 挂载扫描与采集页)。 */
@AndroidEntryPoint
class CollectFragment : PlaceholderFragment()

View File

@@ -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<MountItem> = 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 我的"),
)

View File

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

View File

@@ -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 12API 31Build.VERSION_CODES.S起蓝牙改运行时权限
* BLUETOOTH_SCAN/CONNECTSCAN 已声明 neverForLocation扫描不触发定位义务
* API 30 及以下为 BLUETOOTH/BLUETOOTH_ADMIN 安装期权限 + 运行时 ACCESS_FINE_LOCATION。
* 与 Manifest 的 maxSdkVersion="30" 划线一致功能明细表所写「12L 分版」在工程上
* 以 API 31 为界(新权限模型生效版本)。
*/
object BlePermissions {
/** 当前系统版本需运行时申请的权限组。 */
fun required(): Array<String> =
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))
}

View File

@@ -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] 为平台测点 IDM5 平台测点源交付前手动测点均为空串占位。
*/
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<Any> = emptyList(),
) : CollectEvent
}

View File

@@ -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())
}
}
}

View File

@@ -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=pendingM6 通道落地
* 前只入队不上传,离线优先);
* - 断连即发事件提示会话运行中且自动重连开启时按指数退避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>(CollectSessionState.Idle)
/** 会话状态流UI 门控渲染)。 */
val session: StateFlow<CollectSessionState> = _session.asStateFlow()
private val _gattState = MutableStateFlow<GattState>(GattState.Disconnected(STATUS_IDLE))
/** 当前连接 GATT 四态流M4-03。 */
val gattState: StateFlow<GattState> = _gattState.asStateFlow()
private val _latestSample = MutableStateFlow<CollectSample?>(null)
/** 最新一条解析读数M4-04/05 实时展示)。 */
val latestSample: StateFlow<CollectSample?> = _latestSample.asStateFlow()
private val _sampleCount = MutableStateFlow(0)
/** 本次会话累计解析条数(= 入队条数)。 */
val sampleCount: StateFlow<Int> = _sampleCount.asStateFlow()
/** 断连提示 / 重连进度 / 激励 ack 一次性事件流。 */
val events = MutableSharedFlow<CollectEvent>(extraBufferCapacity = 16)
private val _autoReconnect = MutableStateFlow(true)
/** 自动重连开关M4-09 可关)。 */
val autoReconnect: StateFlow<Boolean> = _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<CollectSample> = 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 = "蓝牙采集"
}
}

View File

@@ -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 交互编排 + 会话/连接/数据流转发)。
*
* 门控沿用 M3SignedIn 且三要素就绪才可见扫描区;会话离开 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<Boolean?>(null)
/** null=未询问true=已授权false=被拒绝。 */
val permissionGranted: StateFlow<Boolean?> = _permissionGranted.asStateFlow()
// ---- 演示模式 ----
val demoMode = switchableDriver.demoMode
// ---- M4-02 扫描 ----
private val _scanning = MutableStateFlow(false)
val scanning: StateFlow<Boolean> = _scanning.asStateFlow()
private val _devices = MutableStateFlow<List<ScannedDevice>>(emptyList())
val devices: StateFlow<List<ScannedDevice>> = _devices.asStateFlow()
private val _selectedDevice = MutableStateFlow<ScannedDevice?>(null)
val selectedDevice: StateFlow<ScannedDevice?> = _selectedDevice.asStateFlow()
private var scanJob: Job? = null
// ---- M4-08 任务与测点 ----
private val _tasks = MutableStateFlow<UiState<List<TaskItem>>>(UiState.Idle)
val tasks: StateFlow<UiState<List<TaskItem>>> = _tasks.asStateFlow()
private val _selectedTask = MutableStateFlow<TaskItem?>(null)
val selectedTask: StateFlow<TaskItem?> = _selectedTask.asStateFlow()
val points: StateFlow<List<MeasurementPoint>> = pointSource.points
private val _selectedPoint = MutableStateFlow("")
val selectedPoint: StateFlow<String> = _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<CollectEvent>(extraBufferCapacity = 16)
/** 一次性提示事件(会话事件转发 + 本页交互提示)。 */
val events: SharedFlow<CollectEvent> = _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<ScannedDevice>,
incoming: ScannedDevice,
): List<ScannedDevice> {
val index = current.indexOfFirst { it.address == incoming.address }
return if (index < 0) current + incoming
else current.toMutableList().also { it[index] = incoming }
}
}

View File

@@ -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<ScannedDevice>) {
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<BottomNavigationView>(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,
}
}

View File

@@ -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<ScannedDevice, DeviceListAdapter.DeviceViewHolder>(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<ScannedDevice>() {
override fun areItemsTheSame(oldItem: ScannedDevice, newItem: ScannedDevice) =
oldItem.address == newItem.address
override fun areContentsTheSame(oldItem: ScannedDevice, newItem: ScannedDevice) =
oldItem == newItem
}
}
}

View File

@@ -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] 为平台测点 IDM5 交付前手动测点为空串)。 */
data class MeasurementPoint(
val id: String,
val name: String,
)
/**
* 测点源抽象M4-08 预留接口)。
*
* M4 交付 [ManualPointSource] 本地手动源M5 子任务回填真实平台测点源
* (按任务测组拉取 GetPointBySurveyGroup后经 Hilt 替换绑定,采集会话零改动。
*/
interface MeasurementPointSource {
/** 可用测点流UI 下拉/历史展示M5 实现按任务测组实时刷新)。 */
val points: StateFlow<List<MeasurementPoint>>
/** 拉取 [task] 测组可用测点task 为 null 时返回本地手动测点。 */
suspend fun pointsOf(task: TaskItem?): List<MeasurementPoint>
/** 手动录入测点;空名/重复返回既有项或 null。 */
suspend fun record(name: String): MeasurementPoint?
}
/** 本地手动测点源(会话内存态,应用进程内保留历史录入)。 */
@Singleton
class ManualPointSource @Inject constructor() : MeasurementPointSource {
private val manual = MutableStateFlow<List<MeasurementPoint>>(emptyList())
override val points: StateFlow<List<MeasurementPoint>> = manual.asStateFlow()
override suspend fun pointsOf(task: TaskItem?): List<MeasurementPoint> = 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
}
}

View File

@@ -0,0 +1,540 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 采集工作台M4-01 权限 / M4-02 扫描 / M4-03 连接 / M4-08 会话 / M4-04~06 数据与下发 / M4-09 重连开关) -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 会话门控(未登录/缺配置/未选项目) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_gate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_gate_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_gate_action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/collect_gate_go_login" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M4-01 权限动线:未授权时引导,拒绝后引导系统设置 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_permission_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:alpha="0.7"
android:text="@string/collect_permission_hint"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_grant_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/collect_permission_grant" />
<TextView
android:id="@+id/tv_permission_denied"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/collect_permission_denied_hint"
android:textColor="?attr/colorError"
android:textSize="12sp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_open_settings"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/collect_permission_go_settings"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 演示模式开关(无 BLE 硬件环境全动线可走) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_demo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardCornerRadius="14dp">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_demo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="@string/collect_demo_switch"
android:textSize="13sp" />
</com.google.android.material.card.MaterialCardView>
<!-- M4-02 扫描 + M4-03 连接状态 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_connect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_connect_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/collect_scan_start" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_scan"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:nestedScrollingEnabled="false" />
<TextView
android:id="@+id/tv_scan_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.6"
android:text="@string/collect_scan_empty"
android:textSize="12sp"
android:visibility="gone" />
<!-- 已连接设备行M4-03 状态流 + M4-09 自动重连开关) -->
<LinearLayout
android:id="@+id/row_connected"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_connected_device"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_gatt_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_disconnect"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/collect_disconnect" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_auto_reconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/collect_auto_reconnect"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M4-08 采集会话:绑定任务与测点 + 开始/停止 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_session"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_session_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_task"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/collect_pick_task" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_point"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/collect_point_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_point"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/tv_bound_target"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:textSize="12sp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_start_collect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/collect_start" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_stop_collect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:backgroundTint="?attr/colorError"
android:text="@string/collect_stop"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M4-04/05 实时数据 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_data_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_sample_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.6"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/group_pressure"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:text="@string/collect_value_pressure"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_pressure"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/group_temperature"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:text="@string/collect_value_temperature"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_temperature"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/group_vm_row1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:text="@string/collect_value_frequency"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_frequency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:text="@string/collect_value_voltage"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_voltage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/group_quality"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.6"
android:text="@string/collect_value_quality"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_quality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/tv_waiting_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.6"
android:text="@string/collect_waiting_data"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M4-06 VM208 激励下发 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_excitation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_excitation_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_excitation"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/collect_excitation_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_excitation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_send_excitation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/collect_excitation_send" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 扫描结果单行:设备名 + 类型 + 信号强度 + 连接 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="8dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_device_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.6"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_device_connect"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:minWidth="0dp"
android:paddingHorizontal="14dp"
android:text="@string/collect_device_connect"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -127,4 +127,71 @@
<string name="change_password_confirm_hint">确认新密码</string>
<string name="change_password_submit">提交修改</string>
<string name="change_password_success">密码已修改,请使用新密码重新登录</string>
<string name="collect_gate_signed_out">登录后即可扫描采集仪并采集数据</string>
<string name="collect_gate_no_config">请先在「我的」中配置服务器与密钥</string>
<string name="collect_gate_no_project">请先到首页选择当前项目,采集数据将绑定该项目</string>
<string name="collect_gate_go_login">去登录</string>
<string name="collect_gate_go_config">去配置</string>
<string name="collect_gate_go_home">去选择项目</string>
<!-- M4 采集·蓝牙M4-01~09 -->
<string name="collect_permission_title">需要蓝牙权限</string>
<string name="collect_permission_hint">设备扫描与数据采集依赖蓝牙权限Android 12 及以下还需定位权限,仅用于扫描附近采集仪,不上传位置)。</string>
<string name="collect_permission_grant">授予蓝牙权限</string>
<string name="collect_permission_denied_hint">权限被拒绝,无法扫描设备;可到系统设置中手动开启。</string>
<string name="collect_permission_go_settings">去系统设置</string>
<string name="collect_demo_switch">演示模式(无蓝牙硬件时灌入样例数据)</string>
<string name="collect_demo_on">演示模式已开启,扫描到的是演示设备</string>
<string name="collect_demo_off">演示模式已关闭</string>
<string name="collect_demo_block_in_session">采集中不能切换演示模式,请先停止采集</string>
<string name="collect_scan_start">开始扫描</string>
<string name="collect_scan_stop">停止扫描</string>
<string name="collect_scan_empty">未发现设备:请确认采集仪已开机并靠近手机</string>
<string name="collect_device_type_ie">振弦读数仪</string>
<string name="collect_device_type_vm">综合采集仪</string>
<string name="collect_device_rssi_fmt">%1$d dBm</string>
<string name="collect_device_connect">连接</string>
<string name="collect_connected_device">当前设备</string>
<string name="collect_state_disconnected">未连接</string>
<string name="collect_state_connecting">连接中…</string>
<string name="collect_state_connected">已连接·发现服务</string>
<string name="collect_state_ready">已就绪</string>
<string name="collect_disconnect">断开</string>
<string name="collect_auto_reconnect">自动重连(断开后指数退避)</string>
<string name="collect_connect_title">设备连接</string>
<string name="collect_session_title">采集会话</string>
<string name="collect_pick_task">选择任务</string>
<string name="collect_task_none">不绑定任务</string>
<string name="collect_task_fmt">%1$s · %2$s · %3$s</string>
<string name="collect_point_label">测点</string>
<string name="collect_point_hint">输入或选择测点名M5 平台测点源待接入)</string>
<string name="collect_start">开始采集</string>
<string name="collect_stop">停止采集</string>
<string name="collect_need_device">请先选择设备并连接</string>
<string name="collect_need_point">请先填写测点名</string>
<string name="collect_sample_count_fmt">已采集 %1$d 条离线暂存M6 上传)</string>
<string name="collect_data_title">实时数据</string>
<string name="collect_value_pressure">压力</string>
<string name="collect_value_temperature">温度</string>
<string name="collect_value_frequency">频率</string>
<string name="collect_value_voltage">电压</string>
<string name="collect_value_quality">元件质量</string>
<string name="collect_quality_good">良好</string>
<string name="collect_quality_fair">一般</string>
<string name="collect_quality_none">无元件</string>
<string name="collect_quality_unknown">未知</string>
<string name="collect_waiting_data">等待数据…</string>
<string name="collect_excitation_title">VM208 激励下发</string>
<string name="collect_excitation_hint">激励值065535</string>
<string name="collect_excitation_send">下发</string>
<string name="collect_excitation_ack_fmt">激励已设置:%1$d</string>
<string name="collect_excitation_invalid">请输入 065535 范围内的整数</string>
<string name="collect_excitation_not_ready">设备未就绪,无法下发</string>
<string name="collect_event_connected_fmt">已连接 %1$s</string>
<string name="collect_event_disconnected">连接已断开</string>
<string name="collect_event_disconnected_reconnect">连接断开,将自动重连</string>
<string name="collect_event_reconnecting_fmt">第 %1$d 次重连(约 %2$d 秒后重试)</string>
<string name="collect_scan_failed_fmt">扫描失败code=%1$d</string>
<string name="collect_task_not_bound">未绑定任务</string>
</resources>

View File

@@ -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\""))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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