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:
@@ -1,7 +0,0 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 采集占位(M4+M5 数据采集,S2/S3 挂载扫描与采集页)。 */
|
||||
@AndroidEntryPoint
|
||||
class CollectFragment : PlaceholderFragment()
|
||||
@@ -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 我的"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<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))
|
||||
}
|
||||
@@ -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<Any> = emptyList(),
|
||||
) : CollectEvent
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>(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 = "蓝牙采集"
|
||||
}
|
||||
}
|
||||
@@ -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<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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
540
app/src/main/res/layout/fragment_collect.xml
Normal file
540
app/src/main/res/layout/fragment_collect.xml
Normal 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>
|
||||
43
app/src/main/res/layout/item_ble_device.xml
Normal file
43
app/src/main/res/layout/item_ble_device.xml
Normal 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>
|
||||
@@ -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">激励值(0–65535)</string>
|
||||
<string name="collect_excitation_send">下发</string>
|
||||
<string name="collect_excitation_ack_fmt">激励已设置:%1$d</string>
|
||||
<string name="collect_excitation_invalid">请输入 0–65535 范围内的整数</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>
|
||||
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,38 +15,88 @@ import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
|
||||
/**
|
||||
* GATT 连接状态机(M4-03 四态口径)。
|
||||
*
|
||||
* - [Connecting]:connectGatt 已发起,等待链路建立;
|
||||
* - [Connected]:链路建立,服务发现中;
|
||||
* - [Ready]:服务发现成功(MTU 协商由 [BleSession.awaitReady] 内尽力而为完成),
|
||||
* 可订阅 Notify / 下发写入;
|
||||
* - [Disconnected]:链路断开或服务发现失败,status 为 GATT 错误码,会话终结。
|
||||
*/
|
||||
sealed interface GattState {
|
||||
data object Connecting : GattState
|
||||
data object Connected : GattState
|
||||
data object ServicesDiscovered : GattState
|
||||
data object Ready : GattState
|
||||
data class Disconnected(val status: Int) : GattState
|
||||
}
|
||||
|
||||
/**
|
||||
* 单设备 GATT 连接会话骨架。
|
||||
* 单条 BLE 连接的驱动级抽象(M4 无硬件环境的 FakeBleDriver 演示路线依赖此接口)。
|
||||
* 不泄露 BluetoothGatt 类型,纯 Kotlin 可实现。
|
||||
*/
|
||||
interface BleConnection {
|
||||
/** 连接状态流;Disconnected 后连接即终结。 */
|
||||
val state: StateFlow<GattState>
|
||||
|
||||
/** 挂起等待连接就绪(服务发现成功);失败/超时抛异常。 */
|
||||
suspend fun awaitReady(timeoutMillis: Long = DEFAULT_TIMEOUT)
|
||||
|
||||
/**
|
||||
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
|
||||
* 帧定界与解析由上层(ble-protocol)负责,本流按 Notify 回调原样发射。
|
||||
*/
|
||||
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray>
|
||||
|
||||
/** 写特征(VM208 激励命令下发)。返回是否写入成功。 */
|
||||
suspend fun write(
|
||||
profile: BleUuids.Profile,
|
||||
data: ByteArray,
|
||||
timeoutMillis: Long = DEFAULT_TIMEOUT,
|
||||
): Boolean
|
||||
|
||||
/** MTU 协商(M4-03);设备不支持/协商失败返回 false,不阻塞采集(帧长均 < 默认 23B)。 */
|
||||
suspend fun requestMtu(mtu: Int): Boolean
|
||||
|
||||
/** 主动断开并释放 GATT 资源。 */
|
||||
fun disconnect()
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TIMEOUT = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单设备 GATT 连接会话(系统 BluetoothGatt 实现)。
|
||||
*
|
||||
* 用法(S2/S3 业务侧):
|
||||
* 用法:
|
||||
* ```
|
||||
* val session = connector.connect(scanned)
|
||||
* session.state.filter { it is GattState.ServicesDiscovered }.first()
|
||||
* session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
|
||||
* val connection = driver.connect(scanned)
|
||||
* connection.awaitReady()
|
||||
* connection.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
|
||||
* val readings = parser.feed(bytes)
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ⚠ write 采用单挂起桥(单 CompletableDeferred 槽),上层须串行下发
|
||||
* (采集页 UI 单入口已保证)。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleSession internal constructor(
|
||||
context: Context,
|
||||
address: String,
|
||||
) {
|
||||
) : BleConnection {
|
||||
|
||||
private val bluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
@@ -62,49 +112,56 @@ class BleSession internal constructor(
|
||||
|
||||
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
|
||||
|
||||
/** 连接状态流;Disconnected 后会话即终结。 */
|
||||
val state: StateFlow<GattState> = _state
|
||||
|
||||
/** 等待服务发现完成(连接后调用一次)。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
suspend fun awaitServicesDiscovered(timeoutMillis: Long = DEFAULT_TIMEOUT) {
|
||||
withTimeout(timeoutMillis) {
|
||||
state.filter { it != GattState.Connecting && it != GattState.Connected }.first()
|
||||
}
|
||||
}
|
||||
override val state: StateFlow<GattState> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
|
||||
* 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
|
||||
* Notify 字节总线:BluetoothGatt 的回调只送达 connectGatt 注册的主 callback
|
||||
* 实例(S0 骨架曾误在 notifyBytes 内自建 listener,任何回调都不会路由到它),
|
||||
* 因此字节统一由主 callback 转发到此流,[notifyBytes] 订阅消费。
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
|
||||
private val notifyBus = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
|
||||
/** MTU 协商挂起桥:onMtuChanged 一次性回填。 */
|
||||
private var mtuRequest: CompletableDeferred<Boolean>? = null
|
||||
|
||||
override suspend fun awaitReady(timeoutMillis: Long) {
|
||||
val terminal = withTimeout(timeoutMillis) {
|
||||
state.filter { it is GattState.Ready || it is GattState.Disconnected }.first()
|
||||
}
|
||||
check(terminal is GattState.Ready) {
|
||||
"连接未就绪(GATT status=${(terminal as? GattState.Disconnected)?.status})"
|
||||
}
|
||||
// MTU 尽力协商到 185:两设备帧长 ≤ 27B,失败不阻塞采集
|
||||
requestMtu(NEGOTIATED_MTU)
|
||||
}
|
||||
|
||||
override fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
|
||||
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
|
||||
val characteristic = checkNotNull(
|
||||
gatt.getService(profile.service)?.getCharacteristic(profile.notifyCharacteristic),
|
||||
) { "Notify 特征不存在: ${profile.notifyCharacteristic}" }
|
||||
return callbackFlow {
|
||||
val listener = object : BluetoothGattCallback() {
|
||||
@Deprecated("API 33 起走带 characteristic 参数的新回调;骨架期两版共存")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
if (characteristic.uuid == profile.notifyCharacteristic) {
|
||||
trySend(characteristic.value ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
check(gatt.setCharacteristicNotification(characteristic, true)) {
|
||||
"开启 Notify 失败: ${profile.notifyCharacteristic}"
|
||||
}
|
||||
gatt.writeDescriptor(
|
||||
characteristic.getDescriptor(DESCRIPTOR_CCCD)?.apply {
|
||||
value = ENABLE_NOTIFICATION_VALUE
|
||||
},
|
||||
)
|
||||
// 单回调实例仅做字节监听(连接生命周期由主 callback 管理)
|
||||
val cccd = checkNotNull(characteristic.getDescriptor(DESCRIPTOR_CCCD)) {
|
||||
"Notify 特征缺少 CCCD 描述符"
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
// API 33+ 同步返回 GATT 状态码
|
||||
check(
|
||||
gatt.writeDescriptor(cccd, ENABLE_NOTIFICATION_VALUE) ==
|
||||
BluetoothGatt.GATT_SUCCESS,
|
||||
) { "写 CCCD 失败" }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
check(
|
||||
gatt.writeDescriptor(cccd.apply { value = ENABLE_NOTIFICATION_VALUE }),
|
||||
) { "写 CCCD 失败" }
|
||||
}
|
||||
val pump = launch { notifyBus.collect(::trySend) }
|
||||
awaitClose {
|
||||
pump.cancel()
|
||||
runCatching {
|
||||
gatt.setCharacteristicNotification(characteristic, false)
|
||||
}
|
||||
@@ -112,29 +169,63 @@ class BleSession internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 写特征(VM208 激励命令下发)。返回是否写入成功。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): Boolean {
|
||||
override suspend fun write(
|
||||
profile: BleUuids.Profile,
|
||||
data: ByteArray,
|
||||
timeoutMillis: Long,
|
||||
): Boolean {
|
||||
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
|
||||
val characteristic = gatt.getService(profile.service)
|
||||
?.getCharacteristic(profile.writeCharacteristic)
|
||||
?: return false
|
||||
val done = CompletableDeferred<Boolean>()
|
||||
writeListener.completeWith = done
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
characteristic.value = data
|
||||
if (!gatt.writeCharacteristic(characteristic)) {
|
||||
done.complete(false)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
// API 33+ 同步返回 GATT 状态码,非 SUCCESS 直接失败
|
||||
val result = gatt.writeCharacteristic(characteristic, data, WRITE_TYPE)
|
||||
if (result != BluetoothGatt.GATT_SUCCESS) done.complete(false)
|
||||
} else {
|
||||
legacyWrite(characteristic, data, done)
|
||||
}
|
||||
return withTimeout(timeoutMillis) { done.await() }
|
||||
}
|
||||
|
||||
/** 主动断开并释放 GATT 资源。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
private fun legacyWrite(
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
done: CompletableDeferred<Boolean>,
|
||||
) {
|
||||
characteristic.writeType = WRITE_TYPE
|
||||
characteristic.value = data
|
||||
if (!gatt!!.writeCharacteristic(characteristic)) done.complete(false)
|
||||
}
|
||||
|
||||
override suspend fun requestMtu(mtu: Int): Boolean {
|
||||
val gatt = gatt ?: return false
|
||||
val done = CompletableDeferred<Boolean>()
|
||||
mtuRequest = done
|
||||
if (!gatt.requestMtu(mtu)) {
|
||||
mtuRequest = null
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
withTimeout(BleConnection.DEFAULT_TIMEOUT) { done.await() }
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
mtuRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
}
|
||||
|
||||
private fun emitNotify(characteristic: BluetoothGattCharacteristic, bytes: ByteArray?) {
|
||||
if (bytes != null && bytes.isNotEmpty()) notifyBus.tryEmit(bytes)
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
@@ -152,28 +243,50 @@ class BleSession internal constructor(
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
_state.value = GattState.ServicesDiscovered
|
||||
_state.value = GattState.Ready
|
||||
} else {
|
||||
_state.value = GattState.Disconnected(status)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
mtuRequest?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
writeListener.completeWith?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
// API 26~32 走旧签名;API 33+ 只走带 value 的新签名(缺失则 33+ 收不到字节)
|
||||
@Deprecated("API 33 起走带 characteristic/value 的新回调,双版本共存")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
emitNotify(characteristic, characteristic.value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
emitNotify(characteristic, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
|
||||
private val writeListener = object {
|
||||
lateinit var completeWith: CompletableDeferred<Boolean>
|
||||
var completeWith: CompletableDeferred<Boolean>? = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_TIMEOUT = 10_000L
|
||||
const val NEGOTIATED_MTU = 185
|
||||
val WRITE_TYPE: Int = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
|
||||
/** Client Characteristic Config 描述符(Notify 开关)。 */
|
||||
val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
|
||||
@@ -184,8 +297,7 @@ class BleSession internal constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* GATT 连接入口:按扫描结果建立会话。
|
||||
* S0 骨架不引 Hilt,由 app/S3 按需构造或包装。
|
||||
* GATT 连接入口:按扫描结果建立会话([SystemBleDriver] 的底层装配件)。
|
||||
*/
|
||||
class BleConnector(private val context: Context) {
|
||||
|
||||
|
||||
23
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt
Normal file
23
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleDriver.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* BLE 驱动抽象(M4 全动线的硬件无关层):
|
||||
* - [SystemBleDriver]:系统 BluetoothLeScanner/BluetoothGatt 真实实现;
|
||||
* - [FakeBleDriver]:演示模式样例帧实现(无 BLE 硬件环境驱动完整 UI 动线);
|
||||
* - [SwitchableBleDriver]:运行时按演示开关路由。
|
||||
*
|
||||
* 本模块保持零 DI 依赖(限定符绑定由 app 壳 BleModule 完成)。
|
||||
*/
|
||||
interface BleDriver {
|
||||
|
||||
/**
|
||||
* 扫描目标类型设备(名称前缀过滤由实现负责),命中流同地址覆盖发射。
|
||||
* 收集端取消即停止扫描。
|
||||
*/
|
||||
fun scan(types: Set<DeviceType> = DeviceType.entries.toSet()): Flow<ScannedDevice>
|
||||
|
||||
/** 建立连接(异步:状态从 [GattState.Connecting] 起流)。 */
|
||||
fun connect(device: ScannedDevice): BleConnection
|
||||
}
|
||||
196
core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt
Normal file
196
core/ble/src/main/kotlin/com/stec/cmd/core/ble/FakeBleDriver.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* 演示模式 BLE 驱动(无 BLE 硬件环境驱动完整采集动线,M4-04/05 样例帧口径):
|
||||
* - 扫描:渐次发射两台演示设备(IE-1000 / VM208 200001),RSSI 带抖动;
|
||||
* - 连接:约 0.5s Connected、再 0.6s Ready,模拟真实节奏;
|
||||
* - Notify:Ready 后每约 600ms 发射**协议精确**样例帧(Ie1000FrameParser /
|
||||
* Vm208FrameParser 可直接解析,数值随时间波动);
|
||||
* - 写入:VM208 激励命令按协议「原样回显」注入 Notify 流作为 ack。
|
||||
*
|
||||
* 纯 Kotlin 实现,不触碰 android.bluetooth。
|
||||
*/
|
||||
class FakeBleDriver : BleDriver {
|
||||
|
||||
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = flow {
|
||||
while (true) {
|
||||
DEMO_DEVICES.filter { it.type in types }.forEachIndexed { index, device ->
|
||||
// 逐台出现 + 周期性重发(RSSI 微抖动,UI 同地址覆盖刷新)
|
||||
if (index > 0) delay(DEVICE_APPEAR_INTERVAL)
|
||||
emit(device.copy(rssi = device.rssi + JITTER.random()))
|
||||
}
|
||||
delay(RESCAN_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect(device: ScannedDevice): BleConnection = FakeConnection(device)
|
||||
|
||||
private companion object {
|
||||
val DEMO_DEVICES = listOf(
|
||||
ScannedDevice(
|
||||
name = "IE-1000",
|
||||
address = "02:1A:7F:DE:MO:01",
|
||||
rssi = -52,
|
||||
type = DeviceType.IE1000,
|
||||
),
|
||||
ScannedDevice(
|
||||
name = "VM208 200001",
|
||||
address = "02:1A:7F:DE:MO:02",
|
||||
rssi = -63,
|
||||
type = DeviceType.VM208,
|
||||
),
|
||||
)
|
||||
const val DEVICE_APPEAR_INTERVAL = 900L
|
||||
const val RESCAN_INTERVAL = 2_500L
|
||||
val JITTER = intArrayOf(-2, -1, 0, 1, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/** 演示连接:定时器驱动状态机与样例帧流。 */
|
||||
private class FakeConnection(private val device: ScannedDevice) : BleConnection {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
|
||||
override val state: StateFlow<GattState> = _state.asStateFlow()
|
||||
|
||||
/** 写入回显总线(VM208 激励 ack)。 */
|
||||
private val echoBus = MutableSharedFlow<ByteArray>(extraBufferCapacity = 8)
|
||||
private val startedAt = System.currentTimeMillis()
|
||||
private var frameLoop: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
delay(CONNECT_DELAY)
|
||||
_state.value = GattState.Connected
|
||||
delay(SERVICE_DISCOVER_DELAY)
|
||||
if (_state.value == GattState.Connected) _state.value = GattState.Ready
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(timeoutMillis: Long) {
|
||||
val terminal = withTimeout(timeoutMillis) {
|
||||
state.first { it == GattState.Ready || it is GattState.Disconnected }
|
||||
}
|
||||
check(terminal == GattState.Ready) { "演示连接未就绪" }
|
||||
}
|
||||
|
||||
override fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> = callbackFlow {
|
||||
val echoPump = launch { echoBus.collect(::trySend) }
|
||||
frameLoop?.cancel()
|
||||
frameLoop = launch {
|
||||
while (isActive) {
|
||||
trySend(buildSampleFrame())
|
||||
delay(NOTIFY_INTERVAL)
|
||||
}
|
||||
}
|
||||
awaitClose {
|
||||
frameLoop?.cancel()
|
||||
echoPump.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(
|
||||
profile: BleUuids.Profile,
|
||||
data: ByteArray,
|
||||
timeoutMillis: Long,
|
||||
): Boolean {
|
||||
delay(WRITE_DELAY)
|
||||
// 协议约定:设备对激励命令原样回显(0F 0F 06 0A XX XX 0D 0A)
|
||||
if (data.size == ECHO_COMMAND_LENGTH) echoBus.tryEmit(data.copyOf())
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun requestMtu(mtu: Int): Boolean = true
|
||||
|
||||
override fun disconnect() {
|
||||
frameLoop?.cancel()
|
||||
_state.value = GattState.Disconnected(FAKE_CLEAN_STATUS)
|
||||
}
|
||||
|
||||
/** 协议精确的样例帧:数值随时间小幅波动,可在 ble-protocol 解析器中完整复原。 */
|
||||
private fun buildSampleFrame(): ByteArray {
|
||||
val t = (System.currentTimeMillis() - startedAt) / 1000.0
|
||||
return when (device.type) {
|
||||
DeviceType.IE1000 -> {
|
||||
val pressure = (BASE_PRESSURE + sin(t) * PRESSURE_AMPLITUDE).toFloat()
|
||||
val temperature = (BASE_TEMPERATURE + sin(t / 3) * TEMPERATURE_AMPLITUDE).toFloat()
|
||||
ieFrame(pressure, temperature)
|
||||
}
|
||||
|
||||
DeviceType.VM208 -> {
|
||||
val frequency = BASE_FREQUENCY + sin(t) * FREQUENCY_AMPLITUDE
|
||||
val temperature = BASE_TEMPERATURE + sin(t / 3) * TEMPERATURE_AMPLITUDE
|
||||
val voltage = BASE_VOLTAGE + sin(t / 5) * VOLTAGE_AMPLITUDE
|
||||
vmFrame(frequency, temperature, voltage, QUALITY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** IE-1000:'Y' + 4B 小端 float 压力 + 'T' + 4B 小端 float 温度 + 0D 0A。 */
|
||||
private fun ieFrame(pressure: Float, temperature: Float): ByteArray {
|
||||
val buffer = java.nio.ByteBuffer.allocate(12)
|
||||
.order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
buffer.put('Y'.code.toByte())
|
||||
buffer.putFloat(pressure)
|
||||
buffer.put('T'.code.toByte())
|
||||
buffer.putFloat(temperature)
|
||||
buffer.put(0x0D)
|
||||
buffer.put(0x0A)
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
/** VM208:CC F%05d T%03d E%05d S%c AA + 0D 0A(ASCII 字段)。 */
|
||||
private fun vmFrame(
|
||||
frequencyHz: Double,
|
||||
temperatureC: Double,
|
||||
voltageV: Double,
|
||||
quality: Char,
|
||||
): ByteArray {
|
||||
val f = (frequencyHz * 10).roundToInt().coerceIn(0, 99_999)
|
||||
val t = (temperatureC * 10).roundToInt().coerceIn(0, 999)
|
||||
val e = (voltageV * 100).roundToInt().coerceIn(0, 99_999)
|
||||
val text = "CC F%05d T%03d E%05d S%c AA".format(f, t, e, quality)
|
||||
return text.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONNECT_DELAY = 500L
|
||||
const val SERVICE_DISCOVER_DELAY = 600L
|
||||
const val WRITE_DELAY = 120L
|
||||
const val NOTIFY_INTERVAL = 600L
|
||||
const val ECHO_COMMAND_LENGTH = 8
|
||||
const val FAKE_CLEAN_STATUS = 0
|
||||
|
||||
const val BASE_PRESSURE = 13.0
|
||||
const val PRESSURE_AMPLITUDE = 0.8
|
||||
const val BASE_FREQUENCY = 1234.5
|
||||
const val FREQUENCY_AMPLITUDE = 25.0
|
||||
const val BASE_TEMPERATURE = 29.0
|
||||
const val TEMPERATURE_AMPLITUDE = 0.6
|
||||
const val BASE_VOLTAGE = 140.0
|
||||
const val VOLTAGE_AMPLITUDE = 1.5
|
||||
const val QUALITY = 'G'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 可切换驱动:按演示模式开关把 scan/connect 路由到系统或 Fake 实现(M4 演示动线)。
|
||||
* 纯路由不含连接生命周期(切换开关在连接态下由 UI 拒绝,见采集 ViewModel)。
|
||||
*/
|
||||
class SwitchableBleDriver(
|
||||
private val system: BleDriver,
|
||||
private val fake: BleDriver,
|
||||
) : BleDriver {
|
||||
|
||||
private val _demoMode = MutableStateFlow(false)
|
||||
|
||||
/** 演示模式开关(false=系统蓝牙)。 */
|
||||
val demoMode: StateFlow<Boolean> = _demoMode.asStateFlow()
|
||||
|
||||
fun setDemoMode(enabled: Boolean) {
|
||||
_demoMode.value = enabled
|
||||
}
|
||||
|
||||
private val active: BleDriver
|
||||
get() = if (_demoMode.value) fake else system
|
||||
|
||||
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = active.scan(types)
|
||||
|
||||
override fun connect(device: ScannedDevice): BleConnection = active.connect(device)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* 系统 BLE 驱动:包装 [BleScanner] 与 [BleSession],机制中立零协议感知。
|
||||
* 权限由 app 壳申请后调用(扫描 BLUETOOTH_SCAN/定位,连接 BLUETOOTH_CONNECT)。
|
||||
*/
|
||||
class SystemBleDriver(context: Context) : BleDriver {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scanner = BleScanner(appContext)
|
||||
private val connector = BleConnector(appContext)
|
||||
|
||||
override fun scan(types: Set<DeviceType>): Flow<ScannedDevice> = scanner.scan(types)
|
||||
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
override fun connect(device: ScannedDevice): BleConnection = connector.connect(device)
|
||||
}
|
||||
Reference in New Issue
Block a user