APP
This commit is contained in:
parent
a3be0bf6ca
commit
bd8fd53597
|
|
@ -2,6 +2,5 @@
|
|||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/../../../SDK_APP(1)/SDK_APP" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -565,22 +565,23 @@ std::vector<SensorData> parse_device_data(const std::vector<uint8_t>& file_data)
|
|||
return final_results;
|
||||
}
|
||||
|
||||
// CRC16校验函数 (Modbus CRC16)
|
||||
// CRC16校验函数 (CCITT CRC16)
|
||||
uint16_t calculate_crc16(const uint8_t* data, size_t length) {
|
||||
uint16_t crc = 0xFFFF;
|
||||
uint16_t wCRCin = 0xFFFF;
|
||||
const uint16_t wCPoly = 0x1021;
|
||||
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
crc ^= data[i];
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if (crc & 0x0001) {
|
||||
crc = (crc >> 1) ^ 0xA001;
|
||||
} else {
|
||||
crc = crc >> 1;
|
||||
bool bit = ((data[i] >> (7 - j)) & 1) == 1;
|
||||
bool c15 = ((wCRCin >> 15) & 1) == 1;
|
||||
wCRCin = wCRCin << 1;
|
||||
if (c15 ^ bit) {
|
||||
wCRCin = wCRCin ^ wCPoly;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc;
|
||||
return wCRCin & 0xFFFF;
|
||||
}
|
||||
|
||||
// 工具函数:将数值转换为十六进制字符串
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ import java.util.UUID
|
|||
*/
|
||||
class BluetoothManager(private val context: Context) {
|
||||
|
||||
// PPTM命令编码器
|
||||
private val pptmEncoder = PPTMCommandEncoder()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothManager"
|
||||
|
||||
|
|
@ -44,22 +41,14 @@ class BluetoothManager(private val context: Context) {
|
|||
|
||||
// 功能码定义
|
||||
const val FUNC_QUERY_DEVICE_INFO = 0x0000 // 查询设备信息
|
||||
const val FUNC_START_COLLECTION = 0x0001 // 开启采集
|
||||
const val FUNC_QUERY_BATTERY = 0x0002 // 查询电量
|
||||
const val FUNC_SET_SAMPLE_RATE = 0x0003 // 设置采样率
|
||||
const val FUNC_SET_GAIN = 0x0004 // 设置增益
|
||||
const val FUNC_SET_FILTER = 0x0005 // 设置滤波器
|
||||
const val FUNC_SET_LEAD = 0x0006 // 设置导联
|
||||
const val FUNC_SET_ALARM = 0x0007 // 设置报警
|
||||
const val FUNC_SET_TIME = 0x0008 // 设置时间
|
||||
const val FUNC_SET_DATE = 0x0009 // 设置日期
|
||||
const val FUNC_POWER_LINE_FILTER = 0x000A // 工频滤波
|
||||
const val FUNC_QUERY_STATUS = 0x000B // 查询状态
|
||||
const val FUNC_RESET_DEVICE = 0x000C // 设备复位
|
||||
const val FUNC_UPDATE_FIRMWARE = 0x000D // 固件升级
|
||||
const val FUNC_DATA_STREAM = 0x8000 // 数据流
|
||||
const val FUNC_ALARM_DATA = 0x8001 // 报警数据
|
||||
const val FUNC_STATUS_REPORT = 0x8002 // 状态报告
|
||||
const val FUNC_START_COLLECTION = 0x0001 // 采集使能开关(带时间戳)
|
||||
const val FUNC_QUERY_BATTERY = 0x0002 // 电量查询
|
||||
const val FUNC_STIM_SWITCH = 0x0003 // 电刺激开关
|
||||
const val FUNC_POWER_LINE_FILTER = 0x000A // 工频滤波开关
|
||||
const val FUNC_SYNC_TIMESTAMP = 0x0080 // 同步时间戳(64bit毫秒)
|
||||
const val FUNC_DATA_STREAM = 0x8000 // 数据上传
|
||||
const val FUNC_ALARM_DATA = 0x8001 // 设备状态上报
|
||||
const val FUNC_STATUS_REPORT = 0x8002 // 电量上报
|
||||
|
||||
// 数据包类型
|
||||
const val PACKET_TYPE_COMMAND = 0x00 // 命令包
|
||||
|
|
@ -75,31 +64,7 @@ class BluetoothManager(private val context: Context) {
|
|||
const val POWER_LINE_FILTER_OFF = 0x00 // 关闭工频滤波
|
||||
const val POWER_LINE_FILTER_ON = 0x01 // 开启工频滤波
|
||||
|
||||
// 采样率
|
||||
const val SAMPLE_RATE_125 = 125 // 125Hz
|
||||
const val SAMPLE_RATE_250 = 250 // 250Hz
|
||||
const val SAMPLE_RATE_500 = 500 // 500Hz
|
||||
const val SAMPLE_RATE_1000 = 1000 // 1000Hz
|
||||
|
||||
// 增益设置
|
||||
const val GAIN_1 = 1 // 1倍增益
|
||||
const val GAIN_2 = 2 // 2倍增益
|
||||
const val GAIN_4 = 4 // 4倍增益
|
||||
const val GAIN_8 = 8 // 8倍增益
|
||||
|
||||
// 导联设置
|
||||
const val LEAD_I = 0x01 // I导联
|
||||
const val LEAD_II = 0x02 // II导联
|
||||
const val LEAD_III = 0x03 // III导联
|
||||
const val LEAD_AVR = 0x04 // aVR导联
|
||||
const val LEAD_AVL = 0x05 // aVL导联
|
||||
const val LEAD_AVF = 0x06 // aVF导联
|
||||
const val LEAD_V1 = 0x07 // V1导联
|
||||
const val LEAD_V2 = 0x08 // V2导联
|
||||
const val LEAD_V3 = 0x09 // V3导联
|
||||
const val LEAD_V4 = 0x0A // V4导联
|
||||
const val LEAD_V5 = 0x0B // V5导联
|
||||
const val LEAD_V6 = 0x0C // V6导联
|
||||
// 已移除:采样率、增益、导联设置常量(按PDF协议未定义)
|
||||
|
||||
// 响应状态码
|
||||
const val RESPONSE_SUCCESS = 0x00 // 成功
|
||||
|
|
@ -156,9 +121,7 @@ class BluetoothManager(private val context: Context) {
|
|||
// 协议状态
|
||||
private var deviceInfo: DeviceInfo? = null
|
||||
private var isCollecting = false
|
||||
private var currentSampleRate = Protocol.SAMPLE_RATE_500
|
||||
private var currentGain = Protocol.GAIN_1
|
||||
private var currentLead = Protocol.LEAD_II
|
||||
// 已移除:采样率、增益、导联状态变量(按PDF协议未定义)
|
||||
private var powerLineFilterEnabled = false
|
||||
|
||||
// 数据统计
|
||||
|
|
@ -908,32 +871,47 @@ class BluetoothManager(private val context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 查询设备信息
|
||||
* 功能码: 0x0000
|
||||
* 电刺激开关
|
||||
* 功能码: 0x0003
|
||||
* 数据格式: [开关及类型(1字节)]
|
||||
* 数据长度字段表示整个包的长度(7字节)
|
||||
*/
|
||||
fun queryDeviceInfo() {
|
||||
fun stimSwitch(stimType: Int, enable: Boolean) {
|
||||
try {
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_QUERY_DEVICE_INFO)
|
||||
Log.d(TAG, "发送查询设备信息指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
val data = ByteArray(1)
|
||||
// 开关及类型控制
|
||||
// 高4位:开关控制(0000=关闭,0001=开启)
|
||||
// 低4位:刺激类型(0000~1111,即0-15)
|
||||
val switchBits = if (enable) 0x10 else 0x00 // 高4位:0001或0000
|
||||
val typeBits = stimType and 0x0F // 低4位:确保在0-15范围内
|
||||
data[0] = (switchBits or typeBits).toByte()
|
||||
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_STIM_SWITCH, data)
|
||||
Log.d(TAG, "发送电刺激开关指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建查询设备信息指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建查询设备信息指令失败: ${e.message}")
|
||||
Log.e(TAG, "构建电刺激开关指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建电刺激开关指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询电量
|
||||
* 功能码: 0x0002
|
||||
* 同步时间戳
|
||||
* 功能码: 0x0080
|
||||
* 数据格式: [时间戳(8字节, 小端, 毫秒)]
|
||||
*/
|
||||
fun queryBattery() {
|
||||
fun syncTimestamp(timestampMs: Long) {
|
||||
try {
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_QUERY_BATTERY)
|
||||
Log.d(TAG, "发送查询电量指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
val data = ByteArray(8)
|
||||
for (i in 0..7) {
|
||||
data[i] = ((timestampMs shr (i * 8)) and 0xFF).toByte()
|
||||
}
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_SYNC_TIMESTAMP, data)
|
||||
Log.d(TAG, "发送同步时间戳指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建查询电量指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建查询电量指令失败: ${e.message}")
|
||||
Log.e(TAG, "构建同步时间戳指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建同步时间戳指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -942,67 +920,21 @@ class BluetoothManager(private val context: Context) {
|
|||
* 功能码: 0x0003
|
||||
* 数据格式: [采样率(2字节)]
|
||||
*/
|
||||
fun setSampleRate(sampleRate: Int) {
|
||||
try {
|
||||
val data = ByteArray(2)
|
||||
data[0] = (sampleRate and 0xFF).toByte()
|
||||
data[1] = ((sampleRate shr 8) and 0xFF).toByte()
|
||||
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_SET_SAMPLE_RATE, data)
|
||||
Log.d(TAG, "发送设置采样率指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
|
||||
currentSampleRate = sampleRate
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建设置采样率指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建设置采样率指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// 已移除:设置采样率(按PDF未列出)
|
||||
|
||||
/**
|
||||
* 设置增益
|
||||
* 功能码: 0x0004
|
||||
* 数据格式: [增益(1字节)]
|
||||
*/
|
||||
fun setGain(gain: Int) {
|
||||
try {
|
||||
val data = ByteArray(1)
|
||||
data[0] = gain.toByte()
|
||||
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_SET_GAIN, data)
|
||||
Log.d(TAG, "发送设置增益指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
|
||||
currentGain = gain
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建设置增益指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建设置增益指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// 已移除:设置增益(按PDF未列出)
|
||||
|
||||
/**
|
||||
* 设置导联
|
||||
* 功能码: 0x0006
|
||||
* 数据格式: [导联(1字节)]
|
||||
*/
|
||||
fun setLead(lead: Int) {
|
||||
try {
|
||||
val data = ByteArray(1)
|
||||
data[0] = lead.toByte()
|
||||
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_SET_LEAD, data)
|
||||
Log.d(TAG, "发送设置导联指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
|
||||
currentLead = lead
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建设置导联指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建设置导联指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// 已移除:设置导联(按PDF未列出)
|
||||
|
||||
/**
|
||||
* 工频滤波开关
|
||||
|
|
@ -1030,31 +962,13 @@ class BluetoothManager(private val context: Context) {
|
|||
* 查询状态
|
||||
* 功能码: 0x000B
|
||||
*/
|
||||
fun queryStatus() {
|
||||
try {
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_QUERY_STATUS)
|
||||
Log.d(TAG, "发送查询状态指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建查询状态指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建查询状态指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// 已移除:查询状态(按PDF未列出)
|
||||
|
||||
/**
|
||||
* 设备复位
|
||||
* 功能码: 0x000C
|
||||
*/
|
||||
fun resetDevice() {
|
||||
try {
|
||||
val packet = buildProtocolPacket(Protocol.FUNC_RESET_DEVICE)
|
||||
Log.d(TAG, "发送设备复位指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "构建设备复位指令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "构建设备复位指令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// 已移除:设备复位(按PDF未列出)
|
||||
|
||||
/**
|
||||
* 获取协议统计信息
|
||||
|
|
@ -1066,9 +980,7 @@ class BluetoothManager(private val context: Context) {
|
|||
"totalPacketsReceived" to totalPacketsReceived,
|
||||
"totalBytesReceived" to totalBytesReceived,
|
||||
"isCollecting" to isCollecting,
|
||||
"currentSampleRate" to currentSampleRate,
|
||||
"currentGain" to currentGain,
|
||||
"currentLead" to currentLead,
|
||||
// 已移除:采样率、增益、导联状态(按PDF协议未定义)
|
||||
"powerLineFilterEnabled" to powerLineFilterEnabled,
|
||||
"deviceInfo" to deviceInfo
|
||||
)
|
||||
|
|
@ -1164,21 +1076,21 @@ class BluetoothManager(private val context: Context) {
|
|||
* 计算CRC16-CCITT-FALSE校验
|
||||
*/
|
||||
private fun calculateCRC16(data: ByteArray, offset: Int, length: Int): Int {
|
||||
var crc = 0xFFFF
|
||||
var wCRCin = 0xFFFF
|
||||
val wCPoly = 0x1021
|
||||
|
||||
for (i in offset until offset + length) {
|
||||
crc = crc xor (data[i].toInt() and 0xFF)
|
||||
for (j in 0..7) {
|
||||
if ((crc and 0x0001) != 0) {
|
||||
crc = crc shr 1
|
||||
crc = crc xor 0x8408
|
||||
} else {
|
||||
crc = crc shr 1
|
||||
val bit = ((data[i].toInt() shr (7 - j)) and 1) == 1
|
||||
val c15 = ((wCRCin shr 15) and 1) == 1
|
||||
wCRCin = wCRCin shl 1
|
||||
if (c15 xor bit) {
|
||||
wCRCin = wCRCin xor wCPoly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc
|
||||
return wCRCin and 0xFFFF
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1691,80 +1603,4 @@ class BluetoothManager(private val context: Context) {
|
|||
isCleaningUp = true
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PPTM设备命令方法 ====================
|
||||
|
||||
/**
|
||||
* 发送PPTM开始采样命令
|
||||
* 使用PPTM协议编码器生成带CRC16校验的命令
|
||||
*/
|
||||
fun sendPptmStartSampleCmd() {
|
||||
try {
|
||||
val command = pptmEncoder.createPptmStartSampleCmd()
|
||||
Log.d(TAG, "发送PPTM开始采样命令: ${command.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(command)
|
||||
callback?.onStatusChanged("📤 已发送PPTM开始采样命令")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "发送PPTM开始采样命令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "发送PPTM开始采样命令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送PPTM停止采样命令
|
||||
* 使用PPTM协议编码器生成带CRC16校验的命令
|
||||
*/
|
||||
fun sendPptmStopSampleCmd() {
|
||||
try {
|
||||
val command = pptmEncoder.createPptmStopSampleCmd()
|
||||
Log.d(TAG, "发送PPTM停止采样命令: ${command.joinToString(", ") { "0x%02X".format(it) }}")
|
||||
sendCommand(command)
|
||||
callback?.onStatusChanged("📤 已发送PPTM停止采样命令")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "发送PPTM停止采样命令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "发送PPTM停止采样命令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送自定义PPTM命令
|
||||
* @param opcode 操作码
|
||||
* @param payload 负载数据
|
||||
*/
|
||||
fun sendCustomPptmCmd(opcode: Short, payload: ByteArray) {
|
||||
try {
|
||||
val command = pptmEncoder.createCustomPptmCmd(opcode, payload)
|
||||
Log.d(TAG, "发送自定义PPTM命令: 操作码=0x%04X, 负载=${payload.joinToString(", ") { "0x%02X".format(it) }}".format(opcode))
|
||||
sendCommand(command)
|
||||
callback?.onStatusChanged("📤 已发送自定义PPTM命令: 操作码=0x%04X".format(opcode))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "发送自定义PPTM命令失败: ${e.message}")
|
||||
callback?.onCommandSent(false, "发送自定义PPTM命令失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证PPTM数据包CRC16校验
|
||||
* @param packet 完整数据包
|
||||
* @return 校验是否通过
|
||||
*/
|
||||
fun verifyPptmPacket(packet: ByteArray): Boolean {
|
||||
return pptmEncoder.verifyCrc16(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PPTM数据包
|
||||
* @param packet 完整数据包
|
||||
* @return 解析结果 (操作码, 负载数据) 或 null
|
||||
*/
|
||||
fun parsePptmPacket(packet: ByteArray): Pair<Short, ByteArray>? {
|
||||
return pptmEncoder.parsePacket(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PPTM编码器实例(用于调试)
|
||||
*/
|
||||
fun getPptmEncoder(): PPTMCommandEncoder {
|
||||
return pptmEncoder
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.util.Log
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 完整数据处理管道示例
|
||||
* 演示从原始数据到最终指标的完整流程
|
||||
*/
|
||||
class CompletePipelineExample {
|
||||
|
||||
private val dataMapper = DataMapper()
|
||||
private val indicatorCalculator = IndicatorCalculator()
|
||||
private val signalProcessor = SignalProcessorJNI()
|
||||
|
||||
private var dataMapperInitialized = false
|
||||
private var indicatorCalculatorInitialized = false
|
||||
private var signalProcessorInitialized = false
|
||||
|
||||
/**
|
||||
* 初始化完整管道
|
||||
*/
|
||||
fun initialize(): Boolean {
|
||||
Log.d("CompletePipeline", "初始化完整数据处理管道...")
|
||||
|
||||
// 初始化数据映射器
|
||||
dataMapperInitialized = dataMapper.initialize()
|
||||
if (!dataMapperInitialized) {
|
||||
Log.e("CompletePipeline", "数据映射器初始化失败")
|
||||
return false
|
||||
}
|
||||
|
||||
// 初始化指标计算器
|
||||
indicatorCalculatorInitialized = indicatorCalculator.initialize()
|
||||
if (!indicatorCalculatorInitialized) {
|
||||
Log.e("CompletePipeline", "指标计算器初始化失败")
|
||||
cleanup()
|
||||
return false
|
||||
}
|
||||
|
||||
// 初始化信号处理器
|
||||
try {
|
||||
signalProcessorInitialized = signalProcessor.createProcessor()
|
||||
if (!signalProcessorInitialized) {
|
||||
Log.e("CompletePipeline", "信号处理器初始化失败")
|
||||
cleanup()
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("CompletePipeline", "信号处理器初始化异常: ${e.message}")
|
||||
cleanup()
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d("CompletePipeline", "完整数据处理管道初始化成功")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个数据包的完整流程
|
||||
* 原始数据 → 解析数据 → 通道映射 → 信号处理 → 指标计算
|
||||
*/
|
||||
fun processCompletePipeline(rawData: SensorData, sampleRate: Float = 250.0f): Map<String, Float>? {
|
||||
if (!isInitialized()) {
|
||||
Log.w("CompletePipeline", "管道未初始化")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.d("CompletePipeline", "开始处理完整数据管道...")
|
||||
Log.d("CompletePipeline", "输入数据类型: ${rawData.dataType}, 采样率: $sampleRate Hz")
|
||||
|
||||
try {
|
||||
// 步骤1: 通道映射
|
||||
Log.d("CompletePipeline", "步骤1: 执行通道映射...")
|
||||
val mappedData = dataMapper.mapSensorData(rawData)
|
||||
if (mappedData == null) {
|
||||
Log.e("CompletePipeline", "通道映射失败")
|
||||
return null
|
||||
}
|
||||
Log.d("CompletePipeline", "通道映射完成")
|
||||
|
||||
// 步骤2: 信号处理 (这里可以添加信号处理逻辑)
|
||||
Log.d("CompletePipeline", "步骤2: 执行信号处理...")
|
||||
// 暂时跳过信号处理,直接使用映射后的数据
|
||||
val processedData = mappedData
|
||||
Log.d("CompletePipeline", "信号处理完成")
|
||||
|
||||
// 步骤3: 指标计算
|
||||
Log.d("CompletePipeline", "步骤3: 执行指标计算...")
|
||||
val metrics = indicatorCalculator.processCompletePipeline(processedData, sampleRate)
|
||||
if (metrics == null) {
|
||||
Log.e("CompletePipeline", "指标计算失败")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.d("CompletePipeline", "指标计算完成,计算了 ${metrics.size} 个指标")
|
||||
Log.d("CompletePipeline", "完整数据处理管道执行成功")
|
||||
|
||||
// 打印主要指标
|
||||
metrics["heart_rate"]?.let {
|
||||
Log.d("CompletePipeline", "心率: ${String.format("%.1f", it)} bpm")
|
||||
}
|
||||
metrics["signal_quality"]?.let {
|
||||
Log.d("CompletePipeline", "信号质量: ${String.format("%.2f", it)}")
|
||||
}
|
||||
metrics["spo2"]?.let {
|
||||
Log.d("CompletePipeline", "血氧饱和度: ${String.format("%.1f", it)}%")
|
||||
}
|
||||
|
||||
return metrics
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("CompletePipeline", "完整数据处理管道异常: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量处理数据包
|
||||
*/
|
||||
fun processBatchPipeline(rawDataList: List<SensorData>, sampleRate: Float = 250.0f): List<Map<String, Float>> {
|
||||
if (!isInitialized()) {
|
||||
Log.w("CompletePipeline", "管道未初始化")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d("CompletePipeline", "开始批量处理 ${rawDataList.size} 个数据包...")
|
||||
|
||||
val results = mutableListOf<Map<String, Float>>()
|
||||
var successCount = 0
|
||||
|
||||
for ((index, rawData) in rawDataList.withIndex()) {
|
||||
val metrics = processCompletePipeline(rawData, sampleRate)
|
||||
if (metrics != null) {
|
||||
results.add(metrics)
|
||||
successCount++
|
||||
} else {
|
||||
results.add(emptyMap())
|
||||
}
|
||||
|
||||
// 每处理100个数据包打印一次进度
|
||||
if ((index + 1) % 100 == 0) {
|
||||
Log.d("CompletePipeline", "已处理 ${index + 1}/${rawDataList.size} 个数据包")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("CompletePipeline", "批量处理完成: 成功 $successCount/${rawDataList.size} 个数据包")
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查管道是否已初始化
|
||||
*/
|
||||
fun isInitialized(): Boolean {
|
||||
return dataMapperInitialized && indicatorCalculatorInitialized && signalProcessorInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
if (dataMapperInitialized) {
|
||||
// 使用公共方法或直接设置为false
|
||||
dataMapperInitialized = false
|
||||
}
|
||||
|
||||
if (indicatorCalculatorInitialized) {
|
||||
// 使用公共方法或直接设置为false
|
||||
indicatorCalculatorInitialized = false
|
||||
}
|
||||
|
||||
if (signalProcessorInitialized) {
|
||||
signalProcessor.destroyProcessor()
|
||||
signalProcessorInitialized = false
|
||||
}
|
||||
|
||||
Log.d("CompletePipelineExample", "资源清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("CompletePipelineExample", "资源清理异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
object Constants {
|
||||
// UI更新相关
|
||||
const val UPDATE_INTERVAL = 200L // 优化:每200毫秒更新一次UI,减慢更新速度以便观察心电波形
|
||||
|
||||
// 数据分块相关
|
||||
const val CHUNK_SIZE = 64 // 数据分块大小
|
||||
|
||||
// 缓冲区管理相关
|
||||
const val BUFFER_CLEANUP_THRESHOLD = 50 // 缓冲区清理阈值
|
||||
const val BUFFER_KEEP_COUNT = 30 // 缓冲区保留数量
|
||||
|
||||
// 显示限制相关
|
||||
const val MAX_DISPLAY_PACKETS = 5 // 最大显示数据包数量
|
||||
const val MAX_DETAIL_PACKETS = 3 // 最大详情数据包数量
|
||||
const val MAX_DISPLAY_CHANNELS = 4 // 最大显示通道数量
|
||||
const val MAX_12LEAD_CHANNELS = 6 // 12导联心电最大通道数量
|
||||
const val MAX_DISPLAY_SAMPLES = 750 // 最大显示采样点数量,调整为显示约3秒数据(基于250Hz采样率)
|
||||
|
||||
// 文件相关
|
||||
const val DEFAULT_DATA_FILE = "ecg_data_raw.dat" // 默认数据文件名
|
||||
|
||||
// 延迟相关
|
||||
const val SIMULATION_DELAY = 1L // 模拟传输延迟(毫秒)- 减少延迟
|
||||
}
|
||||
|
|
@ -7,6 +7,17 @@ import java.io.IOException
|
|||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* 原生方法回调接口
|
||||
* 用于DataManager调用MainActivity中的原生方法
|
||||
*/
|
||||
interface NativeMethodCallback {
|
||||
fun createStreamParser(): Long
|
||||
fun destroyStreamParser(handle: Long)
|
||||
fun streamParserAppend(handle: Long, chunk: ByteArray)
|
||||
fun streamParserDrainPackets(handle: Long): List<SensorData>?
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据管理类
|
||||
* 负责数据解析、缓冲管理和原生方法调用
|
||||
|
|
@ -165,45 +176,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
// 注释掉后台处理,只显示原始数据
|
||||
// processStreamingData(packets)
|
||||
} else {
|
||||
Log.w("DataManager", "没有解析出有效数据包,尝试生成测试数据")
|
||||
|
||||
// 如果原生解析器没有解析出数据包,生成一些测试数据来验证图表显示
|
||||
generateTestDataForChart()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试数据用于验证图表显示
|
||||
*/
|
||||
private fun generateTestDataForChart() {
|
||||
try {
|
||||
Log.d("DataManager", "生成测试数据用于验证图表显示")
|
||||
|
||||
// 生成模拟的单导联ECG数据
|
||||
val testData = mutableListOf<Float>()
|
||||
val sampleCount = 100 // 生成100个样本点
|
||||
|
||||
for (i in 0 until sampleCount) {
|
||||
val t = i.toFloat() / 10f // 时间参数
|
||||
val value = (Math.sin(t * 2 * Math.PI) * 100 +
|
||||
Math.sin(t * 4 * Math.PI) * 50 +
|
||||
Math.sin(t * 8 * Math.PI) * 25).toFloat()
|
||||
testData.add(value)
|
||||
}
|
||||
|
||||
Log.d("DataManager", "生成测试数据: ${testData.size} 个样本点")
|
||||
Log.d("DataManager", "测试数据前5个值: ${testData.take(5).joinToString(", ")}")
|
||||
|
||||
// 发送测试数据到图表
|
||||
if (realTimeCallback != null) {
|
||||
realTimeCallback!!.onRawDataAvailable(0, testData)
|
||||
Log.d("DataManager", "已发送测试数据到图表")
|
||||
} else {
|
||||
Log.e("DataManager", "realTimeCallback 为空,无法发送测试数据")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "生成测试数据失败: ${e.message}", e)
|
||||
Log.w("DataManager", "没有解析出有效数据包")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -649,48 +622,17 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
/**
|
||||
* 处理文件数据
|
||||
*/
|
||||
fun processFileData(fileData: ByteArray, progressCallback: ((Int) -> Unit)? = null) {
|
||||
Log.d("DataManager", "开始处理文件数据,数据大小: ${fileData.size} 字节")
|
||||
|
||||
try {
|
||||
val chunkSize = 1024
|
||||
var processedBytes = 0
|
||||
|
||||
for (i in fileData.indices step chunkSize) {
|
||||
val endIndex = minOf(i + chunkSize, fileData.size)
|
||||
val chunk = fileData.copyOfRange(i, endIndex)
|
||||
|
||||
onBleNotify(chunk)
|
||||
processedBytes += chunk.size
|
||||
|
||||
// 性能优化:减少进度更新频率
|
||||
if (processedBytes % (chunkSize * 10) == 0) {
|
||||
val progress = (processedBytes * 100 / fileData.size).coerceAtMost(100)
|
||||
progressCallback?.invoke(progress)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("DataManager", "文件数据处理完成,总共处理了 $processedBytes 字节")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "处理文件数据时发生错误: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Getter方法
|
||||
fun getPacketBufferSize(): Int = packetBuffer.size
|
||||
fun getProcessedPacketsSize(): Int = processedPackets.size
|
||||
fun getCalculatedMetricsSize(): Int = calculatedMetrics.size
|
||||
fun getRawStreamSize(): Int = rawStream.size()
|
||||
fun getTotalPacketsParsed(): Long = totalPacketsParsed
|
||||
fun getPacketBuffer(): List<SensorData> = packetBuffer
|
||||
fun getProcessedPackets(): List<SensorData> = processedPackets
|
||||
fun getCalculatedMetrics(): List<Map<String, Float>> = calculatedMetrics
|
||||
fun getLatestMetrics(): Map<String, Float> = latestMetrics
|
||||
fun getChannelBuffersStatus(): Map<Int, Int> = channelBuffers.mapValues { it.value.size }
|
||||
|
||||
// 流式处理相关getter方法
|
||||
fun getProcessedChannelBuffersStatus(): Map<Int, Int> = processedChannelBuffers.mapValues { it.value.size }
|
||||
fun getProcessedChannelData(channelIndex: Int): List<Float> = processedChannelBuffers[channelIndex] ?: emptyList()
|
||||
fun getTotalProcessedSamples(): Long = totalProcessedSamples
|
||||
fun getCurrentDataType(): type.SensorData.DataType? = currentDataType
|
||||
|
|
@ -716,168 +658,9 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算信号质量
|
||||
*/
|
||||
fun calculateSignalQuality(packet: SensorData): Float {
|
||||
if (!streamingSignalProcessorInitialized || streamingSignalProcessor == null) return 0.0f
|
||||
|
||||
try {
|
||||
// 获取第一个通道的数据进行质量评估
|
||||
val channelData = packet.getChannelData()
|
||||
if (channelData != null && channelData.isNotEmpty()) {
|
||||
val firstChannel = channelData[0]
|
||||
if (firstChannel != null) {
|
||||
val signal = firstChannel.toFloatArray()
|
||||
val quality = streamingSignalProcessor!!.calculateSignalQuality(signal)
|
||||
|
||||
// 添加调试信息
|
||||
Log.d("DataManager", "信号质量计算: 通道0, 数据长度=${signal.size}, 质量=$quality")
|
||||
return quality
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "计算信号质量时发生错误: ${e.message}")
|
||||
}
|
||||
|
||||
return 0.0f
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用信号处理
|
||||
*/
|
||||
fun applySignalProcessing(packets: List<SensorData>): List<SensorData> {
|
||||
Log.d("DataManager", "开始应用信号处理,处理 ${packets.size} 个数据包")
|
||||
|
||||
ensureStreamingSignalProcessor()
|
||||
if (!streamingSignalProcessorInitialized || streamingSignalProcessor == null) {
|
||||
Log.w("DataManager", "流式信号处理器未初始化,跳过信号处理")
|
||||
return packets
|
||||
}
|
||||
|
||||
val processedPackets = mutableListOf<SensorData>()
|
||||
|
||||
for (packet in packets) {
|
||||
try {
|
||||
val processedPacket = processSinglePacket(packet)
|
||||
processedPackets.add(processedPacket)
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "处理数据包时发生错误: ${e.message}")
|
||||
processedPackets.add(packet) // 处理失败时返回原始数据包
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("DataManager", "信号处理完成,处理了 ${processedPackets.size} 个数据包")
|
||||
return processedPackets
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个数据包
|
||||
*/
|
||||
private fun processSinglePacket(packet: SensorData): SensorData {
|
||||
val channelData = packet.getChannelData()
|
||||
if (channelData == null || channelData.isEmpty()) {
|
||||
return packet
|
||||
}
|
||||
|
||||
val processedChannels = mutableListOf<List<Float>>()
|
||||
|
||||
for (channel in channelData) {
|
||||
val processedChannel = when (packet.getDataType()) {
|
||||
type.SensorData.DataType.EEG -> applyEEGFilters(channel)
|
||||
type.SensorData.DataType.ECG_2LEAD, type.SensorData.DataType.ECG_12LEAD -> applyECGFilters(channel)
|
||||
type.SensorData.DataType.PPG -> channel // PPG不进行滤波处理,直接返回原始数据
|
||||
else -> channel // 其他类型暂时不处理
|
||||
}
|
||||
processedChannels.add(processedChannel)
|
||||
}
|
||||
|
||||
// 创建处理后的数据包
|
||||
val processedPacket = SensorData()
|
||||
processedPacket.setDataType(packet.getDataType())
|
||||
processedPacket.setTimestamp(packet.getTimestamp())
|
||||
processedPacket.setPacketSn(packet.getPacketSn())
|
||||
processedPacket.setChannelData(processedChannels)
|
||||
|
||||
return processedPacket
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用EEG滤波器
|
||||
*/
|
||||
private fun applyEEGFilters(channel: List<Float>): List<Float> {
|
||||
val signal = channel.toFloatArray()
|
||||
|
||||
try {
|
||||
if (streamingSignalProcessor != null) {
|
||||
// 1. 带通滤波 (1-40Hz,EEG主要频率范围)
|
||||
val bandpassFiltered = streamingSignalProcessor!!.bandpassFilter(
|
||||
signal,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
1.0f,
|
||||
40.0f
|
||||
)
|
||||
|
||||
if (bandpassFiltered != null) {
|
||||
// 2. 幅度归一化
|
||||
val normalized = streamingSignalProcessor!!.normalizeAmplitude(bandpassFiltered)
|
||||
|
||||
if (normalized != null) {
|
||||
return normalized.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "EEG滤波处理失败: ${e.message}")
|
||||
}
|
||||
|
||||
return channel // 处理失败时返回原始数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用ECG滤波器
|
||||
*/
|
||||
private fun applyECGFilters(channel: List<Float>): List<Float> {
|
||||
val signal = channel.toFloatArray()
|
||||
|
||||
try {
|
||||
if (streamingSignalProcessor != null) {
|
||||
// 1. 高通滤波 (0.5Hz,去除基线漂移)
|
||||
val highpassFiltered = streamingSignalProcessor!!.highpassFilter(
|
||||
signal,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
0.5f
|
||||
)
|
||||
|
||||
if (highpassFiltered != null) {
|
||||
// 2. 低通滤波 (40Hz,去除高频噪声)
|
||||
val lowpassFiltered = streamingSignalProcessor!!.lowpassFilter(
|
||||
highpassFiltered,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
40.0f
|
||||
)
|
||||
|
||||
if (lowpassFiltered != null) {
|
||||
// 3. 陷波滤波 (50Hz,去除工频干扰)
|
||||
val notchFiltered = streamingSignalProcessor!!.notchFilter(
|
||||
lowpassFiltered,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
50.0f,
|
||||
10.0f
|
||||
)
|
||||
|
||||
if (notchFiltered != null) {
|
||||
return notchFiltered.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "ECG滤波处理失败: ${e.message}")
|
||||
}
|
||||
|
||||
return channel // 处理失败时返回原始数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓冲区
|
||||
|
|
@ -982,39 +765,6 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
|
||||
|
||||
|
||||
/**
|
||||
* 应用PPG滤波器
|
||||
*/
|
||||
private fun applyPPGFilters(channel: List<Float>): List<Float> {
|
||||
val signal = channel.toFloatArray()
|
||||
|
||||
try {
|
||||
if (streamingSignalProcessor != null) {
|
||||
// 1. 低通滤波 (8Hz,PPG主要频率范围)
|
||||
val filtered = streamingSignalProcessor!!.lowpassFilter(
|
||||
signal,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
8.0f
|
||||
)
|
||||
|
||||
if (filtered != null) {
|
||||
// 2. 去运动伪影
|
||||
val motionArtifactRemoved = streamingSignalProcessor!!.processRealtimeChunk(
|
||||
filtered,
|
||||
filterSettings.sampleRate.toFloat()
|
||||
)
|
||||
|
||||
if (motionArtifactRemoved != null) {
|
||||
return motionArtifactRemoved.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "PPG滤波处理失败: ${e.message}")
|
||||
}
|
||||
|
||||
return channel // 处理失败时返回原始数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置滤波器参数
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 设备类型帮助类
|
||||
* 负责设备类型名称映射和通道数据详情构建
|
||||
*/
|
||||
object DeviceTypeHelper {
|
||||
|
||||
/**
|
||||
* 根据数据类型获取设备名称
|
||||
*/
|
||||
fun getDeviceName(dataType: SensorData.DataType): String {
|
||||
return when (dataType) {
|
||||
SensorData.DataType.EEG -> "脑电设备"
|
||||
SensorData.DataType.ECG_2LEAD -> "胸腹设备"
|
||||
SensorData.DataType.PPG -> "血氧设备"
|
||||
SensorData.DataType.ECG_12LEAD -> "12导联心电"
|
||||
SensorData.DataType.STETHOSCOPE -> "数字听诊"
|
||||
SensorData.DataType.SNORE -> "鼾声设备"
|
||||
SensorData.DataType.RESPIRATION -> "呼吸/姿态"
|
||||
SensorData.DataType.MIT_BIH -> "MIT-BIH"
|
||||
else -> "未知设备"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建通道数据详情字符串
|
||||
*/
|
||||
fun buildChannelDetails(
|
||||
data: List<SensorData>,
|
||||
maxPackets: Int = Constants.MAX_DETAIL_PACKETS,
|
||||
maxChannels: Int = Constants.MAX_DISPLAY_CHANNELS,
|
||||
maxSamples: Int = Constants.MAX_DISPLAY_SAMPLES
|
||||
): String {
|
||||
if (data.isEmpty()) {
|
||||
return "无通道数据"
|
||||
}
|
||||
|
||||
val details = mutableListOf<String>()
|
||||
|
||||
data.take(maxPackets).forEachIndexed { packetIndex, sensorData ->
|
||||
if (sensorData.channelData.isNullOrEmpty()) {
|
||||
details.add("数据包 ${packetIndex + 1}: 无通道数据")
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
details.add("数据包 ${packetIndex + 1} (${getDeviceName(sensorData.dataType ?: SensorData.DataType.EEG)}):")
|
||||
|
||||
sensorData.channelData.take(maxChannels).forEachIndexed { channelIndex, channel ->
|
||||
if (channel.isNullOrEmpty()) {
|
||||
details.add(" 通道 ${channelIndex + 1}: 空数据")
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
// 只显示前几个采样点
|
||||
val sampleCount = minOf(maxSamples, channel.size)
|
||||
val channelDataStr = channel.take(sampleCount).joinToString(", ") { "%.1f".format(it) }
|
||||
|
||||
details.add(" 通道 ${channelIndex + 1}: ${sampleCount}/${channel.size} 采样点")
|
||||
details.add(" 示例: $channelDataStr${if (channel.size > sampleCount) "..." else ""}")
|
||||
}
|
||||
|
||||
if (sensorData.channelData.size > maxChannels) {
|
||||
details.add(" ... 还有 ${sensorData.channelData.size - maxChannels} 个通道")
|
||||
}
|
||||
|
||||
// 添加分隔线
|
||||
details.add("")
|
||||
}
|
||||
|
||||
if (data.size > maxPackets) {
|
||||
details.add("... 还有 ${data.size - maxPackets} 个数据包")
|
||||
}
|
||||
|
||||
return details.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class ECGChartView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint().apply {
|
||||
color = Color.BLUE
|
||||
strokeWidth = 2f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
style = Paint.Style.STROKE
|
||||
alpha = 100
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = 30f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val buttonPaint = Paint().apply {
|
||||
color = Color.GRAY
|
||||
style = Paint.Style.FILL
|
||||
alpha = 150
|
||||
}
|
||||
|
||||
private val buttonTextPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
textSize = 20f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private var dataPoints = mutableListOf<Float>()
|
||||
private var maxDataPoints = 1000 // 优化:显示更多数据点,提供更好的可视化效果
|
||||
private var minValue = Float.MAX_VALUE
|
||||
private var maxValue = Float.MIN_VALUE
|
||||
|
||||
// 缩放控制参数
|
||||
private var scaleX = 1.0f // X轴缩放因子
|
||||
private var scaleY = 1.0f // Y轴缩放因子
|
||||
private var offsetX = 0f // X轴偏移
|
||||
private var offsetY = 0f // Y轴偏移
|
||||
|
||||
// 自动居中控制
|
||||
private var autoCenter = true // 自动居中模式
|
||||
private var lockToCenter = true // 锁定到屏幕中央
|
||||
|
||||
// 手势检测器
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
// 双击重置缩放并锁定到中央
|
||||
resetZoom()
|
||||
lockToCenter = true
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
// 只有在非锁定模式下才允许平移
|
||||
if (!lockToCenter) {
|
||||
offsetX -= distanceX
|
||||
offsetY -= distanceY
|
||||
invalidate()
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮区域
|
||||
private val buttonSize = 60f
|
||||
private val buttonMargin = 20f
|
||||
|
||||
// 缩放按钮区域
|
||||
private val zoomInXButtonRect = RectF()
|
||||
private val zoomOutXButtonRect = RectF()
|
||||
private val zoomInYButtonRect = RectF()
|
||||
private val zoomOutYButtonRect = RectF()
|
||||
private val resetButtonRect = RectF()
|
||||
private val lockButtonRect = RectF()
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
// 快速累积数据
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量(避免内存溢出)
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 快速计算数据范围(减少计算量)
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
// 只计算最近的数据范围,提高性能
|
||||
val recentData = dataPoints.takeLast(minOf(1000, dataPoints.size))
|
||||
minValue = recentData.minOrNull() ?: 0f
|
||||
maxValue = recentData.maxOrNull() ?: 0f
|
||||
|
||||
// 确保有足够的显示范围
|
||||
val range = maxValue - minValue
|
||||
if (range < 0.1f) {
|
||||
val center = (maxValue + minValue) / 2
|
||||
minValue = center - 0.05f
|
||||
maxValue = center + 0.05f
|
||||
} else {
|
||||
// 添加10%的上下边距(减少边距,提高响应速度)
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用自动居中,重置偏移
|
||||
if (autoCenter) {
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
dataPoints.clear()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// 缩放控制方法
|
||||
fun zoomInX() {
|
||||
scaleX = min(scaleX * 1.2f, 5.0f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun zoomOutX() {
|
||||
scaleX = max(scaleX / 1.2f, 0.2f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun zoomInY() {
|
||||
scaleY = min(scaleY * 1.2f, 5.0f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun zoomOutY() {
|
||||
scaleY = max(scaleY / 1.2f, 0.2f)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun resetZoom() {
|
||||
scaleX = 1.0f
|
||||
scaleY = 1.0f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
lockToCenter = true
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// 切换锁定模式
|
||||
fun toggleLockMode() {
|
||||
lockToCenter = !lockToCenter
|
||||
if (lockToCenter) {
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// 获取锁定状态
|
||||
fun isLockedToCenter(): Boolean = lockToCenter
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// 检查是否点击了按钮
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
|
||||
if (zoomInXButtonRect.contains(x, y)) {
|
||||
zoomInX()
|
||||
return true
|
||||
} else if (zoomOutXButtonRect.contains(x, y)) {
|
||||
zoomOutX()
|
||||
return true
|
||||
} else if (zoomInYButtonRect.contains(x, y)) {
|
||||
zoomInY()
|
||||
return true
|
||||
} else if (zoomOutYButtonRect.contains(x, y)) {
|
||||
zoomOutY()
|
||||
return true
|
||||
} else if (resetButtonRect.contains(x, y)) {
|
||||
resetZoom()
|
||||
return true
|
||||
} else if (lockButtonRect.contains(x, y)) {
|
||||
toggleLockMode()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理手势
|
||||
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 更新按钮位置
|
||||
updateButtonPositions(width, height)
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制标题
|
||||
canvas.drawText("ECG Channel 1 (滤波后)", 20f, 40f, textPaint)
|
||||
|
||||
// 绘制缩放信息
|
||||
canvas.drawText("X缩放: ${String.format("%.1f", scaleX)}x", 20f, 70f, textPaint)
|
||||
canvas.drawText("Y缩放: ${String.format("%.1f", scaleY)}x", 20f, 100f, textPaint)
|
||||
|
||||
// 绘制锁定状态
|
||||
val lockStatus = if (lockToCenter) "🔒 已锁定" else "🔓 已解锁"
|
||||
canvas.drawText(lockStatus, 20f, 130f, textPaint)
|
||||
|
||||
// 绘制数据点数量
|
||||
canvas.drawText("数据点: ${dataPoints.size}", 20f, height - 20f, textPaint)
|
||||
|
||||
// 绘制数据范围
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
canvas.drawText("范围: ${String.format("%.3f", minValue)} - ${String.format("%.3f", maxValue)}",
|
||||
20f, height - 50f, textPaint)
|
||||
}
|
||||
|
||||
// 绘制控制按钮
|
||||
drawControlButtons(canvas)
|
||||
|
||||
// 绘制曲线
|
||||
if (dataPoints.size > 1) {
|
||||
drawCurve(canvas, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateButtonPositions(width: Float, height: Float) {
|
||||
val buttonY = height - buttonSize - buttonMargin
|
||||
|
||||
// X轴缩放按钮
|
||||
zoomInXButtonRect.set(
|
||||
width - buttonSize * 6 - buttonMargin * 6,
|
||||
buttonY,
|
||||
width - buttonSize * 5 - buttonMargin * 5,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
|
||||
zoomOutXButtonRect.set(
|
||||
width - buttonSize * 5 - buttonMargin * 5,
|
||||
buttonY,
|
||||
width - buttonSize * 4 - buttonMargin * 4,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
|
||||
// Y轴缩放按钮
|
||||
zoomInYButtonRect.set(
|
||||
width - buttonSize * 4 - buttonMargin * 4,
|
||||
buttonY,
|
||||
width - buttonSize * 3 - buttonMargin * 3,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
|
||||
zoomOutYButtonRect.set(
|
||||
width - buttonSize * 3 - buttonMargin * 3,
|
||||
buttonY,
|
||||
width - buttonSize * 2 - buttonMargin * 2,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
|
||||
// 重置按钮
|
||||
resetButtonRect.set(
|
||||
width - buttonSize * 2 - buttonMargin * 2,
|
||||
buttonY,
|
||||
width - buttonSize - buttonMargin,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
|
||||
// 锁定按钮
|
||||
lockButtonRect.set(
|
||||
width - buttonSize - buttonMargin,
|
||||
buttonY,
|
||||
width - buttonMargin,
|
||||
buttonY + buttonSize
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawControlButtons(canvas: Canvas) {
|
||||
// 绘制X轴缩放按钮
|
||||
canvas.drawRoundRect(zoomInXButtonRect, 10f, 10f, buttonPaint)
|
||||
canvas.drawText("X+", zoomInXButtonRect.centerX(), zoomInXButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
|
||||
canvas.drawRoundRect(zoomOutXButtonRect, 10f, 10f, buttonTextPaint)
|
||||
canvas.drawText("X-", zoomOutXButtonRect.centerX(), zoomOutXButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
|
||||
// 绘制Y轴缩放按钮
|
||||
canvas.drawRoundRect(zoomInYButtonRect, 10f, 10f, buttonPaint)
|
||||
canvas.drawText("Y+", zoomInYButtonRect.centerX(), zoomInYButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
|
||||
canvas.drawRoundRect(zoomOutYButtonRect, 10f, 10f, buttonPaint)
|
||||
canvas.drawText("Y-", zoomOutYButtonRect.centerX(), zoomOutYButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
|
||||
// 绘制重置按钮
|
||||
canvas.drawRoundRect(resetButtonRect, 10f, 10f, buttonPaint)
|
||||
canvas.drawText("重置", resetButtonRect.centerX(), resetButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
|
||||
// 绘制锁定按钮
|
||||
val lockButtonColor = if (lockToCenter) Color.GREEN else Color.RED
|
||||
buttonPaint.color = lockButtonColor
|
||||
canvas.drawRoundRect(lockButtonRect, 10f, 10f, buttonPaint)
|
||||
canvas.drawText(if (lockToCenter) "🔒" else "🔓", lockButtonRect.centerX(), lockButtonRect.centerY() + 7f, buttonTextPaint)
|
||||
buttonPaint.color = Color.GRAY // 恢复默认颜色
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
// 绘制垂直线
|
||||
val verticalSpacing = width / 10
|
||||
for (i in 0..10) {
|
||||
val x = i * verticalSpacing
|
||||
canvas.drawLine(x, 0f, x, height, gridPaint)
|
||||
}
|
||||
|
||||
// 绘制水平线
|
||||
val horizontalSpacing = height / 8
|
||||
for (i in 0..8) {
|
||||
val y = i * horizontalSpacing
|
||||
canvas.drawLine(0f, y, width, y, gridPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||||
path.reset()
|
||||
|
||||
val padding = 60f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
// 应用缩放和偏移
|
||||
val scaledWidth = drawWidth * scaleX
|
||||
val scaledHeight = drawHeight * scaleY
|
||||
|
||||
// 如果锁定到中央,强制偏移为0
|
||||
val effectiveOffsetX = if (lockToCenter) 0f else offsetX
|
||||
val effectiveOffsetY = if (lockToCenter) 0f else offsetY
|
||||
|
||||
val xStep = scaledWidth / (dataPoints.size - 1)
|
||||
|
||||
for (i in dataPoints.indices) {
|
||||
val x = padding + i * xStep + effectiveOffsetX
|
||||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||||
// 使用0.1到0.9的范围,确保曲线在中间80%的区域显示
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * scaledHeight + effectiveOffsetY
|
||||
|
||||
// 确保点在可见区域内
|
||||
if (x >= padding && x <= width - padding && y >= padding && y <= height - padding) {
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val desiredWidth = 800
|
||||
val desiredHeight = 400
|
||||
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
val width = when (widthMode) {
|
||||
MeasureSpec.EXACTLY -> widthSize
|
||||
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
|
||||
else -> desiredWidth
|
||||
}
|
||||
|
||||
val height = when (heightMode) {
|
||||
MeasureSpec.EXACTLY -> heightSize
|
||||
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
|
||||
else -> desiredHeight
|
||||
}
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* ECG节律视图
|
||||
* 显示10秒(2500点)的连续信号,用于评估心率节律
|
||||
*/
|
||||
class ECGRhythmView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint().apply {
|
||||
color = Color.BLUE
|
||||
strokeWidth = 1.5f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = false // 关闭抗锯齿,提高绘制性能
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 0.5f
|
||||
style = Paint.Style.STROKE
|
||||
alpha = 80
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = 24f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private var dataPoints = mutableListOf<Float>()
|
||||
private var maxDataPoints = 2500 // 10秒数据(250Hz采样率)
|
||||
private var minValue = Float.MAX_VALUE
|
||||
private var maxValue = Float.MIN_VALUE
|
||||
private var isDataAvailable = false // 标记是否有数据
|
||||
|
||||
// 简化的数据管理:直接显示新数据
|
||||
|
||||
// 缩放控制参数
|
||||
private var scaleX = 1.0f
|
||||
private var scaleY = 1.0f
|
||||
private var offsetX = 0f
|
||||
private var offsetY = 0f
|
||||
|
||||
// 手势检测器
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
resetZoom()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
offsetX -= distanceX
|
||||
offsetY -= distanceY
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// 缩放手势检测器
|
||||
private val scaleGestureDetector = android.view.ScaleGestureDetector(context,
|
||||
object : android.view.ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: android.view.ScaleGestureDetector): Boolean {
|
||||
scaleX *= detector.scaleFactor
|
||||
scaleY *= detector.scaleFactor
|
||||
|
||||
// 限制缩放范围
|
||||
scaleX = scaleX.coerceIn(0.1f, 5.0f)
|
||||
scaleY = scaleY.coerceIn(0.1f, 5.0f)
|
||||
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// 触摸事件处理
|
||||
private var lastTouchX = 0f
|
||||
private var lastTouchY = 0f
|
||||
private var isDragging = false
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 更新数据范围
|
||||
updateDataRange(newData)
|
||||
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateDataRange(newData: List<Float>) {
|
||||
// 使用全部数据点计算范围,提供更准确的范围
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
// 计算全部数据点的范围
|
||||
minValue = dataPoints.minOrNull() ?: 0f
|
||||
maxValue = dataPoints.maxOrNull() ?: 0f
|
||||
|
||||
// 确保有足够的显示范围
|
||||
val range = maxValue - minValue
|
||||
if (range < 0.1f) {
|
||||
val center = (maxValue + minValue) / 2
|
||||
minValue = center - 0.05f
|
||||
maxValue = center + 0.05f
|
||||
} else {
|
||||
// 添加10%的上下边距
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
dataPoints.clear()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图表数据
|
||||
*/
|
||||
fun getCurrentData(): List<Float> {
|
||||
return dataPoints.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据统计信息
|
||||
*/
|
||||
fun getDataStats(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"dataPoints" to dataPoints.size,
|
||||
"minValue" to minValue,
|
||||
"maxValue" to maxValue,
|
||||
"range" to (maxValue - minValue),
|
||||
"isDataAvailable" to isDataAvailable
|
||||
)
|
||||
}
|
||||
|
||||
fun resetZoom() {
|
||||
scaleX = 1.0f
|
||||
scaleY = 1.0f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
isDragging = false
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (event.pointerCount == 1) {
|
||||
// 单指拖拽
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
offsetX += deltaX
|
||||
offsetY += deltaY
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
isDragging = true
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
// 如果没有拖拽,可能是点击事件
|
||||
return gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理缩放手势
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制标题
|
||||
canvas.drawText("ECG节律视图 (10秒)", 20f, 30f, textPaint)
|
||||
|
||||
// 绘制时间轴标签
|
||||
canvas.drawText("0s", 20f, height - 10f, textPaint)
|
||||
canvas.drawText("5s", width / 2 - 15f, height - 10f, textPaint)
|
||||
canvas.drawText("10s", width - 50f, height - 10f, textPaint)
|
||||
|
||||
// 绘制数据点数量
|
||||
canvas.drawText("数据点: ${dataPoints.size}", 20f, height - 35f, textPaint)
|
||||
|
||||
// 绘制数据范围
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
canvas.drawText("范围: ${String.format("%.3f", minValue)} - ${String.format("%.3f", maxValue)}",
|
||||
20f, height - 60f, textPaint)
|
||||
}
|
||||
|
||||
// 绘制缩放信息
|
||||
canvas.drawText("X缩放: ${String.format("%.1f", scaleX)}x", width - 150f, 30f, textPaint)
|
||||
canvas.drawText("Y缩放: ${String.format("%.1f", scaleY)}x", width - 150f, 55f, textPaint)
|
||||
|
||||
// 如果没有数据,显示默认内容
|
||||
if (!isDataAvailable) {
|
||||
drawDefaultContent(canvas, width, height)
|
||||
} else {
|
||||
// 绘制曲线
|
||||
if (dataPoints.size > 1) {
|
||||
drawCurve(canvas, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
// 绘制垂直线(时间轴)
|
||||
val verticalSpacing = width / 20 // 20条垂直线,每0.5秒一条
|
||||
for (i in 0..20) {
|
||||
val x = i * verticalSpacing
|
||||
canvas.drawLine(x, 0f, x, height, gridPaint)
|
||||
}
|
||||
|
||||
// 绘制水平线(幅度轴)
|
||||
val horizontalSpacing = height / 10
|
||||
for (i in 0..10) {
|
||||
val y = i * horizontalSpacing
|
||||
canvas.drawLine(0f, y, width, y, gridPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawDefaultContent(canvas: Canvas, width: Float, height: Float) {
|
||||
// 绘制默认的提示信息
|
||||
val centerX = width / 2
|
||||
val centerY = height / 2
|
||||
|
||||
// 绘制提示文字
|
||||
val hintPaint = Paint().apply {
|
||||
color = Color.GRAY
|
||||
textSize = 32f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint)
|
||||
|
||||
// 绘制一个简单的示例波形
|
||||
drawSampleWaveform(canvas, width, height)
|
||||
}
|
||||
|
||||
private fun drawSampleWaveform(canvas: Canvas, width: Float, height: Float) {
|
||||
val samplePaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 2f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
alpha = 100
|
||||
}
|
||||
|
||||
val path = Path()
|
||||
val padding = 80f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
val centerY = height / 2
|
||||
|
||||
path.moveTo(padding, centerY)
|
||||
|
||||
// 绘制一个简单的正弦波示例
|
||||
for (i in 0..100) {
|
||||
val x = padding + (i * drawWidth / 100)
|
||||
val y = centerY + (Math.sin(i * 0.3) * drawHeight / 4).toFloat()
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
|
||||
canvas.drawPath(path, samplePaint)
|
||||
}
|
||||
|
||||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||||
path.reset()
|
||||
|
||||
val padding = 80f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
// 应用缩放和偏移
|
||||
val scaledWidth = drawWidth * scaleX
|
||||
val scaledHeight = drawHeight * scaleY
|
||||
|
||||
// 性能优化:减少数据点绘制,提高流畅度
|
||||
val stepSize = maxOf(1, dataPoints.size / 1000) // 最多绘制1000个点
|
||||
val xStep = scaledWidth / (dataPoints.size - 1)
|
||||
|
||||
var firstPoint = true
|
||||
for (i in dataPoints.indices step stepSize) {
|
||||
val x = padding + i * xStep + offsetX
|
||||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||||
// 使用0.1到0.9的范围,确保曲线在中间80%的区域显示
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * scaledHeight + offsetY
|
||||
|
||||
// 确保点在可见区域内
|
||||
if (x >= padding && x <= width - padding && y >= padding && y <= height - padding) {
|
||||
if (firstPoint) {
|
||||
path.moveTo(x, y)
|
||||
firstPoint = false
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val desiredWidth = 1000
|
||||
val desiredHeight = 300
|
||||
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
val width = when (widthMode) {
|
||||
MeasureSpec.EXACTLY -> widthSize
|
||||
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
|
||||
else -> desiredWidth
|
||||
}
|
||||
|
||||
val height = when (heightMode) {
|
||||
MeasureSpec.EXACTLY -> heightSize
|
||||
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
|
||||
else -> desiredHeight
|
||||
}
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* ECG波形视图
|
||||
* 显示2.5秒(625点)的放大信号,用于精确测量波形形态和间期
|
||||
*/
|
||||
class ECGWaveformView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint().apply {
|
||||
color = Color.RED
|
||||
strokeWidth = 2f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
style = Paint.Style.STROKE
|
||||
alpha = 100
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = 28f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private var dataPoints = mutableListOf<Float>()
|
||||
private var maxDataPoints = 625 // 2.5秒数据(250Hz采样率)
|
||||
private var minValue = Float.MAX_VALUE
|
||||
private var maxValue = Float.MIN_VALUE
|
||||
private var isDataAvailable = false // 标记是否有数据
|
||||
|
||||
// 简化的数据管理:直接显示新数据
|
||||
|
||||
// 缩放控制参数
|
||||
private var scaleX = 1.0f
|
||||
private var scaleY = 1.0f
|
||||
private var offsetX = 0f
|
||||
private var offsetY = 0f
|
||||
|
||||
// 手势检测器
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
resetZoom()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
offsetX -= distanceX
|
||||
offsetY -= distanceY
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// 缩放手势检测器
|
||||
private val scaleGestureDetector = android.view.ScaleGestureDetector(context,
|
||||
object : android.view.ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: android.view.ScaleGestureDetector): Boolean {
|
||||
scaleX *= detector.scaleFactor
|
||||
scaleY *= detector.scaleFactor
|
||||
|
||||
// 限制缩放范围
|
||||
scaleX = scaleX.coerceIn(0.1f, 5.0f)
|
||||
scaleY = scaleY.coerceIn(0.1f, 5.0f)
|
||||
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// 触摸事件处理
|
||||
private var lastTouchX = 0f
|
||||
private var lastTouchY = 0f
|
||||
private var isDragging = false
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 更新数据范围
|
||||
updateDataRange(newData)
|
||||
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateDataRange(newData: List<Float>) {
|
||||
// 使用全部数据点计算范围,提供更准确的范围
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
// 计算全部数据点的范围
|
||||
minValue = dataPoints.minOrNull() ?: 0f
|
||||
maxValue = dataPoints.maxOrNull() ?: 0f
|
||||
|
||||
// 确保有足够的显示范围
|
||||
val range = maxValue - minValue
|
||||
if (range < 0.1f) {
|
||||
val center = (maxValue + minValue) / 2
|
||||
minValue = center - 0.05f
|
||||
maxValue = center + 0.05f
|
||||
} else {
|
||||
// 添加15%的上下边距
|
||||
val margin = range * 0.15f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
dataPoints.clear()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图表数据
|
||||
*/
|
||||
fun getCurrentData(): List<Float> {
|
||||
return dataPoints.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据统计信息
|
||||
*/
|
||||
fun getDataStats(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"dataPoints" to dataPoints.size,
|
||||
"minValue" to minValue,
|
||||
"maxValue" to maxValue,
|
||||
"range" to (maxValue - minValue),
|
||||
"isDataAvailable" to isDataAvailable
|
||||
)
|
||||
}
|
||||
|
||||
fun resetZoom() {
|
||||
scaleX = 1.0f
|
||||
scaleY = 1.0f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
isDragging = false
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (event.pointerCount == 1) {
|
||||
// 单指拖拽
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
offsetX += deltaX
|
||||
offsetY += deltaY
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
isDragging = true
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
// 如果没有拖拽,可能是点击事件
|
||||
return gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理缩放手势
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制标题
|
||||
canvas.drawText("ECG波形视图 (2.5秒)", 20f, 35f, textPaint)
|
||||
|
||||
// 绘制时间轴标签
|
||||
canvas.drawText("0s", 20f, height - 10f, textPaint)
|
||||
canvas.drawText("1.25s", width / 2 - 25f, height - 10f, textPaint)
|
||||
canvas.drawText("2.5s", width - 60f, height - 10f, textPaint)
|
||||
|
||||
// 绘制数据点数量
|
||||
canvas.drawText("数据点: ${dataPoints.size}", 20f, height - 40f, textPaint)
|
||||
|
||||
// 绘制数据范围
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
canvas.drawText("范围: ${String.format("%.3f", minValue)} - ${String.format("%.3f", maxValue)}",
|
||||
20f, height - 70f, textPaint)
|
||||
}
|
||||
|
||||
// 绘制缩放信息
|
||||
canvas.drawText("X缩放: ${String.format("%.1f", scaleX)}x", width - 150f, 35f, textPaint)
|
||||
canvas.drawText("Y缩放: ${String.format("%.1f", scaleY)}x", width - 150f, 65f, textPaint)
|
||||
|
||||
// 如果没有数据,显示默认内容
|
||||
if (!isDataAvailable) {
|
||||
drawDefaultContent(canvas, width, height)
|
||||
} else {
|
||||
// 绘制曲线
|
||||
if (dataPoints.size > 1) {
|
||||
drawCurve(canvas, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
// 绘制垂直线(时间轴)- 更密集的网格用于精确测量
|
||||
val verticalSpacing = width / 25 // 25条垂直线,每0.1秒一条
|
||||
for (i in 0..25) {
|
||||
val x = i * verticalSpacing
|
||||
canvas.drawLine(x, 0f, x, height, gridPaint)
|
||||
}
|
||||
|
||||
// 绘制水平线(幅度轴)
|
||||
val horizontalSpacing = height / 12 // 12条水平线,更密集
|
||||
for (i in 0..12) {
|
||||
val y = i * horizontalSpacing
|
||||
canvas.drawLine(0f, y, width, y, gridPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawDefaultContent(canvas: Canvas, width: Float, height: Float) {
|
||||
// 绘制默认的提示信息
|
||||
val centerX = width / 2
|
||||
val centerY = height / 2
|
||||
|
||||
// 绘制提示文字
|
||||
val hintPaint = Paint().apply {
|
||||
color = Color.GRAY
|
||||
textSize = 28f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint)
|
||||
|
||||
// 绘制一个简单的示例波形
|
||||
drawSampleWaveform(canvas, width, height)
|
||||
}
|
||||
|
||||
private fun drawSampleWaveform(canvas: Canvas, width: Float, height: Float) {
|
||||
val samplePaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1.5f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
alpha = 80
|
||||
}
|
||||
|
||||
val path = Path()
|
||||
val padding = 100f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
val centerY = height / 2
|
||||
|
||||
path.moveTo(padding, centerY)
|
||||
|
||||
// 绘制一个简单的正弦波示例
|
||||
for (i in 0..100) {
|
||||
val x = padding + (i * drawWidth / 100)
|
||||
val y = centerY + (Math.sin(i * 0.5) * drawHeight / 6).toFloat()
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
|
||||
canvas.drawPath(path, samplePaint)
|
||||
}
|
||||
|
||||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||||
path.reset()
|
||||
|
||||
val padding = 100f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
// 应用缩放和偏移
|
||||
val scaledWidth = drawWidth * scaleX
|
||||
val scaledHeight = drawHeight * scaleY
|
||||
|
||||
val xStep = scaledWidth / (dataPoints.size - 1)
|
||||
|
||||
for (i in dataPoints.indices) {
|
||||
val x = padding + i * xStep + offsetX
|
||||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||||
// 使用0.1到0.9的范围,确保曲线在中间80%的区域显示
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * scaledHeight + offsetY
|
||||
|
||||
// 确保点在可见区域内
|
||||
if (x >= padding && x <= width - padding && y >= padding && y <= height - padding) {
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val desiredWidth = 1000
|
||||
val desiredHeight = 400
|
||||
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
val width = when (widthMode) {
|
||||
MeasureSpec.EXACTLY -> widthSize
|
||||
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
|
||||
else -> desiredWidth
|
||||
}
|
||||
|
||||
val height = when (heightMode) {
|
||||
MeasureSpec.EXACTLY -> heightSize
|
||||
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
|
||||
else -> desiredHeight
|
||||
}
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* 文件帮助类
|
||||
* 负责文件读取操作
|
||||
*/
|
||||
object FileHelper {
|
||||
|
||||
/**
|
||||
* 从 assets 文件夹读取文件到字节数组
|
||||
*/
|
||||
fun readAssetFile(context: Context, fileName: String): ByteArray? {
|
||||
return try {
|
||||
context.assets.open(fileName).use { inputStream ->
|
||||
// 使用更可靠的文件读取方式
|
||||
val bytes = mutableListOf<Byte>()
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
for (i in 0 until bytesRead) {
|
||||
bytes.add(buffer[i])
|
||||
}
|
||||
}
|
||||
|
||||
bytes.toByteArray()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileHelper", "Error reading asset file: $fileName", e)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileHelper", "Unexpected error reading asset file: $fileName", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -972,8 +972,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
*/
|
||||
private fun showCommandDialog() {
|
||||
val commands = arrayOf(
|
||||
"轻迅协议指令",
|
||||
"PPTM协议指令"
|
||||
"轻迅协议指令"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
|
|
@ -981,7 +980,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
.setItems(commands) { _, which ->
|
||||
when (which) {
|
||||
0 -> showQingXunProtocolDialog()
|
||||
1 -> showPptmProtocolDialog()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
|
|
@ -995,8 +993,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
val commands = arrayOf(
|
||||
"开启采集",
|
||||
"停止采集",
|
||||
"查询设备信息",
|
||||
"查询电量",
|
||||
"电刺激开关",
|
||||
"工频滤波开关"
|
||||
)
|
||||
|
||||
|
|
@ -1006,87 +1003,8 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
when (which) {
|
||||
0 -> sendQingXunStartCollection()
|
||||
1 -> sendQingXunStopCollection()
|
||||
2 -> bluetoothManager.queryDeviceInfo()
|
||||
3 -> bluetoothManager.queryBattery()
|
||||
4 -> showPowerLineFilterDialog()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示PPTM协议指令对话框
|
||||
*/
|
||||
private fun showPptmProtocolDialog() {
|
||||
val commands = arrayOf(
|
||||
"开始采样",
|
||||
"停止采样",
|
||||
"自定义PPTM指令"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("PPTM协议指令")
|
||||
.setItems(commands) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
bluetoothManager.sendPptmStartSampleCmd()
|
||||
updateStatus("发送PPTM开始采样指令")
|
||||
}
|
||||
1 -> {
|
||||
bluetoothManager.sendPptmStopSampleCmd()
|
||||
updateStatus("发送PPTM停止采样指令")
|
||||
}
|
||||
2 -> showCustomPptmCommandDialog()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示自定义PPTM指令对话框
|
||||
*/
|
||||
private fun showCustomPptmCommandDialog() {
|
||||
val input = android.widget.EditText(this)
|
||||
input.hint = "输入操作码(如:0x0001)"
|
||||
|
||||
val payloadInput = android.widget.EditText(this)
|
||||
payloadInput.hint = "输入负载数据(十六进制,如:01 00 00 00)"
|
||||
|
||||
val layout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(50, 20, 50, 20)
|
||||
addView(input)
|
||||
addView(payloadInput)
|
||||
}
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("自定义PPTM指令")
|
||||
.setView(layout)
|
||||
.setPositiveButton("发送") { _, _ ->
|
||||
val opcodeStr = input.text.toString().trim()
|
||||
val payloadStr = payloadInput.text.toString().trim()
|
||||
|
||||
if (opcodeStr.isNotEmpty()) {
|
||||
try {
|
||||
val opcode = if (opcodeStr.startsWith("0x")) {
|
||||
opcodeStr.substring(2).toShort(16)
|
||||
} else {
|
||||
opcodeStr.toShort()
|
||||
}
|
||||
|
||||
val payload = if (payloadStr.isNotEmpty()) {
|
||||
payloadStr.split(" ").map { it.toInt(16).toByte() }.toByteArray()
|
||||
} else {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
bluetoothManager.sendCustomPptmCmd(opcode, payload)
|
||||
updateStatus("发送自定义PPTM指令: 操作码=0x%04X".format(opcode))
|
||||
} catch (e: Exception) {
|
||||
updateStatus("PPTM指令格式错误: ${e.message}")
|
||||
}
|
||||
2 -> showStimSwitchDialog()
|
||||
3 -> showPowerLineFilterDialog()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
|
|
@ -1102,9 +1020,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
*/
|
||||
private fun sendQingXunStartCollection() {
|
||||
val options = arrayOf(
|
||||
"立即开启",
|
||||
"延迟开启(5秒后)",
|
||||
"指定时间戳开启"
|
||||
"立即开启"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
|
|
@ -1115,17 +1031,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
bluetoothManager.startCollection(0L) // 立即开启
|
||||
updateStatus("发送轻迅协议开启采集指令(立即),准备接收数据...")
|
||||
}
|
||||
1 -> {
|
||||
val timestamp = System.currentTimeMillis() + 5000 // 5秒后
|
||||
bluetoothManager.startCollection(timestamp)
|
||||
updateStatus("发送轻迅协议开启采集指令(5秒后),准备接收数据...")
|
||||
}
|
||||
2 -> {
|
||||
showTimestampInputDialog { timestamp ->
|
||||
bluetoothManager.startCollection(timestamp)
|
||||
updateStatus("发送轻迅协议开启采集指令(指定时间戳: $timestamp),准备接收数据...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
|
|
@ -1137,9 +1042,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
*/
|
||||
private fun sendQingXunStopCollection() {
|
||||
val options = arrayOf(
|
||||
"立即停止",
|
||||
"延迟停止(5秒后)",
|
||||
"指定时间戳停止"
|
||||
"立即停止"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
|
|
@ -1150,17 +1053,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
bluetoothManager.stopCollection(0L) // 立即停止
|
||||
updateStatus("发送轻迅协议停止采集指令(立即)")
|
||||
}
|
||||
1 -> {
|
||||
val timestamp = System.currentTimeMillis() + 5000 // 5秒后
|
||||
bluetoothManager.stopCollection(timestamp)
|
||||
updateStatus("发送轻迅协议停止采集指令(5秒后)")
|
||||
}
|
||||
2 -> {
|
||||
showTimestampInputDialog { timestamp ->
|
||||
bluetoothManager.stopCollection(timestamp)
|
||||
updateStatus("发送轻迅协议停止采集指令(指定时间戳: $timestamp)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
|
|
@ -1195,71 +1087,30 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
}
|
||||
|
||||
/**
|
||||
* 显示时间戳输入对话框
|
||||
* 显示电刺激开关对话框
|
||||
*/
|
||||
private fun showTimestampInputDialog(onConfirm: (Long) -> Unit) {
|
||||
val input = android.widget.EditText(this)
|
||||
input.hint = "输入时间戳(毫秒,0表示立即执行)"
|
||||
input.setText("0")
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("输入时间戳")
|
||||
.setView(input)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
try {
|
||||
val timestamp = input.text.toString().toLong()
|
||||
onConfirm(timestamp)
|
||||
} catch (e: NumberFormatException) {
|
||||
updateStatus("时间戳格式错误,请输入数字")
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示轻迅协议命令菜单
|
||||
*/
|
||||
private fun showQingXunProtocolMenu() {
|
||||
val options = arrayOf(
|
||||
"查询设备信息",
|
||||
"开启采集",
|
||||
"停止采集",
|
||||
"查询电量",
|
||||
"设置采样率",
|
||||
"设置增益",
|
||||
"设置导联",
|
||||
"工频滤波开关",
|
||||
"查询状态",
|
||||
"设备复位",
|
||||
"查看协议统计"
|
||||
private fun showStimSwitchDialog() {
|
||||
val stimOptions = arrayOf(
|
||||
"开启刺激A",
|
||||
"开启刺激B",
|
||||
"关闭电刺激"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("轻迅协议V1.0.1命令")
|
||||
.setItems(options) { _, which ->
|
||||
.setTitle("电刺激开关")
|
||||
.setItems(stimOptions) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
bluetoothManager.queryDeviceInfo()
|
||||
updateStatus("发送查询设备信息指令")
|
||||
bluetoothManager.stimSwitch(0, true)
|
||||
updateStatus("发送电刺激开关指令: 刺激A 开启")
|
||||
}
|
||||
1 -> sendQingXunStartCollection()
|
||||
2 -> sendQingXunStopCollection()
|
||||
3 -> {
|
||||
bluetoothManager.queryBattery()
|
||||
updateStatus("发送查询电量指令")
|
||||
1 -> {
|
||||
bluetoothManager.stimSwitch(1, true)
|
||||
updateStatus("发送电刺激开关指令: 刺激B 开启")
|
||||
}
|
||||
4 -> showSampleRateDialog()
|
||||
5 -> showGainDialog()
|
||||
6 -> showLeadDialog()
|
||||
7 -> showPowerLineFilterDialog()
|
||||
8 -> {
|
||||
bluetoothManager.queryStatus()
|
||||
updateStatus("发送查询状态指令")
|
||||
}
|
||||
9 -> {
|
||||
bluetoothManager.resetDevice()
|
||||
updateStatus("发送设备复位指令")
|
||||
2 -> {
|
||||
bluetoothManager.stimSwitch(0, false)
|
||||
updateStatus("发送电刺激开关指令: 关闭电刺激")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1268,150 +1119,15 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
}
|
||||
|
||||
/**
|
||||
* 显示采样率设置对话框
|
||||
* 显示调试状态对话框
|
||||
*/
|
||||
private fun showSampleRateDialog() {
|
||||
val options = arrayOf(
|
||||
"125 Hz",
|
||||
"250 Hz",
|
||||
"500 Hz",
|
||||
"1000 Hz",
|
||||
"2000 Hz",
|
||||
"自定义"
|
||||
)
|
||||
private fun showDebugStatus() {
|
||||
val status = bluetoothManager.debugConnectionStatus()
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("设置采样率")
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> bluetoothManager.setSampleRate(125)
|
||||
1 -> bluetoothManager.setSampleRate(250)
|
||||
2 -> bluetoothManager.setSampleRate(500)
|
||||
3 -> bluetoothManager.setSampleRate(1000)
|
||||
4 -> bluetoothManager.setSampleRate(2000)
|
||||
5 -> showCustomSampleRateDialog()
|
||||
}
|
||||
updateStatus("发送设置采样率指令")
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示自定义采样率对话框
|
||||
*/
|
||||
private fun showCustomSampleRateDialog() {
|
||||
val input = android.widget.EditText(this)
|
||||
input.hint = "输入采样率 (Hz)"
|
||||
input.setText("500")
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("自定义采样率")
|
||||
.setView(input)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
try {
|
||||
val sampleRate = input.text.toString().toInt()
|
||||
if (sampleRate > 0 && sampleRate <= 10000) {
|
||||
bluetoothManager.setSampleRate(sampleRate)
|
||||
updateStatus("发送设置采样率指令: ${sampleRate} Hz")
|
||||
} else {
|
||||
updateStatus("采样率范围错误,请输入1-10000之间的数字")
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
updateStatus("采样率格式错误,请输入数字")
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示增益设置对话框
|
||||
*/
|
||||
private fun showGainDialog() {
|
||||
val options = arrayOf(
|
||||
"1x",
|
||||
"2x",
|
||||
"4x",
|
||||
"8x",
|
||||
"16x",
|
||||
"32x",
|
||||
"自定义"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("设置增益")
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> bluetoothManager.setGain(1)
|
||||
1 -> bluetoothManager.setGain(2)
|
||||
2 -> bluetoothManager.setGain(4)
|
||||
3 -> bluetoothManager.setGain(8)
|
||||
4 -> bluetoothManager.setGain(16)
|
||||
5 -> bluetoothManager.setGain(32)
|
||||
6 -> showCustomGainDialog()
|
||||
}
|
||||
updateStatus("发送设置增益指令")
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示自定义增益对话框
|
||||
*/
|
||||
private fun showCustomGainDialog() {
|
||||
val input = android.widget.EditText(this)
|
||||
input.hint = "输入增益倍数"
|
||||
input.setText("1")
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("自定义增益")
|
||||
.setView(input)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
try {
|
||||
val gain = input.text.toString().toInt()
|
||||
if (gain > 0 && gain <= 100) {
|
||||
bluetoothManager.setGain(gain)
|
||||
updateStatus("发送设置增益指令: ${gain}x")
|
||||
} else {
|
||||
updateStatus("增益范围错误,请输入1-100之间的数字")
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
updateStatus("增益格式错误,请输入数字")
|
||||
}
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示导联设置对话框
|
||||
*/
|
||||
private fun showLeadDialog() {
|
||||
val options = arrayOf(
|
||||
"I导联",
|
||||
"II导联",
|
||||
"III导联",
|
||||
"aVR导联",
|
||||
"aVL导联",
|
||||
"aVF导联",
|
||||
"V1导联",
|
||||
"V2导联",
|
||||
"V3导联",
|
||||
"V4导联",
|
||||
"V5导联",
|
||||
"V6导联"
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("设置导联")
|
||||
.setItems(options) { _, which ->
|
||||
val lead = which + 1 // 导联编号从1开始
|
||||
bluetoothManager.setLead(lead)
|
||||
updateStatus("发送设置导联指令: ${options[which]}")
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.setTitle("协议统计信息")
|
||||
.setMessage(status)
|
||||
.setPositiveButton("确定", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
|
@ -1426,16 +1142,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
updateStatus("已清空所有数据")
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试图表显示功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 显示调试状态对话框
|
||||
*/
|
||||
|
||||
|
||||
|
||||
private fun startDataMonitoring() {
|
||||
if (!isDataMonitoringEnabled) {
|
||||
isDataMonitoringEnabled = true
|
||||
|
|
@ -1572,18 +1278,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
|||
}.start()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 导出设备信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* 导出节律视图数据
|
||||
*/
|
||||
|
||||
/**
|
||||
* 导出波形视图数据
|
||||
*/
|
||||
private fun exportWaveformData(data: List<Float>, stats: Map<String, Any>) {
|
||||
Thread {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 原生方法回调接口
|
||||
* 用于DataManager调用MainActivity中的原生方法
|
||||
*/
|
||||
interface NativeMethodCallback {
|
||||
fun createStreamParser(): Long
|
||||
fun destroyStreamParser(handle: Long)
|
||||
fun streamParserAppend(handle: Long, chunk: ByteArray)
|
||||
fun streamParserDrainPackets(handle: Long): List<SensorData>?
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* PPTM设备命令编码器
|
||||
* 负责编码PPTM设备的命令数据包,包含CRC16校验
|
||||
*/
|
||||
class PPTMCommandEncoder {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PPTMCommandEncoder"
|
||||
private const val MAX_PACKET_SIZE = 244
|
||||
|
||||
// PPTM协议操作码
|
||||
const val OPCODE_START_SAMPLE = 0x0001
|
||||
const val OPCODE_STOP_SAMPLE = 0x0001
|
||||
|
||||
// PPTM协议常量
|
||||
const val COLLECTION_ON = 0x01
|
||||
const val COLLECTION_OFF = 0x00
|
||||
}
|
||||
|
||||
/**
|
||||
* CRC-16-CCITT-FALSE校验
|
||||
* @param source 数据源
|
||||
* @param offset 偏移
|
||||
* @param length 长度
|
||||
* @return CRC校验值
|
||||
*/
|
||||
fun crc16(source: ByteArray, offset: Int, length: Int): Int {
|
||||
var wCRCin = 0xFFFF
|
||||
val wCPoly = 0x1021
|
||||
|
||||
for (i in offset until offset + length) {
|
||||
for (j in 0..7) {
|
||||
val bit = ((source[i].toInt() shr (7 - j)) and 1) == 1
|
||||
val c15 = ((wCRCin shr 15) and 1) == 1
|
||||
wCRCin = wCRCin shl 1
|
||||
if (c15 xor bit) {
|
||||
wCRCin = wCRCin xor wCPoly
|
||||
}
|
||||
}
|
||||
}
|
||||
return wCRCin and 0xFFFF
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码命令数据
|
||||
* @param opcode 操作码
|
||||
* @param payload 负载数据
|
||||
* @param payloadLen 负载长度
|
||||
* @return 编码后的完整数据包
|
||||
*/
|
||||
fun encode(opcode: Short, payload: ByteArray, payloadLen: Short): ByteArray {
|
||||
val buf = ByteBuffer.allocate(MAX_PACKET_SIZE)
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
// 写入操作码
|
||||
buf.putShort(opcode)
|
||||
|
||||
// 写入负载长度
|
||||
buf.putShort(payloadLen)
|
||||
|
||||
// 写入负载数据
|
||||
buf.put(payload)
|
||||
|
||||
// 计算CRC16校验
|
||||
val calcCrcDataLen = buf.position()
|
||||
val crc = crc16(buf.array(), 0, calcCrcDataLen)
|
||||
buf.putShort(crc.toShort())
|
||||
|
||||
// 获取完整数据包
|
||||
val allDataLen = buf.position()
|
||||
val dst = ByteArray(allDataLen)
|
||||
|
||||
buf.flip()
|
||||
buf.get(dst, 0, allDataLen)
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PPTM开始采样命令
|
||||
* @return 编码后的命令
|
||||
*/
|
||||
fun createPptmStartSampleCmd(): ByteArray {
|
||||
val payload = byteArrayOf(
|
||||
COLLECTION_ON.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
return encode(OPCODE_START_SAMPLE.toShort(), payload, payload.size.toShort())
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PPTM停止采样命令
|
||||
* @return 编码后的命令
|
||||
*/
|
||||
fun createPptmStopSampleCmd(): ByteArray {
|
||||
val payload = byteArrayOf(
|
||||
COLLECTION_OFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
return encode(OPCODE_STOP_SAMPLE.toShort(), payload, payload.size.toShort())
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自定义PPTM命令
|
||||
* @param opcode 操作码
|
||||
* @param payload 负载数据
|
||||
* @return 编码后的命令
|
||||
*/
|
||||
fun createCustomPptmCmd(opcode: Short, payload: ByteArray): ByteArray {
|
||||
return encode(opcode, payload, payload.size.toShort())
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据包CRC16校验
|
||||
* @param packet 完整数据包
|
||||
* @return 校验是否通过
|
||||
*/
|
||||
fun verifyCrc16(packet: ByteArray): Boolean {
|
||||
if (packet.size < 6) return false // 至少需要操作码(2) + 长度(2) + CRC(2)
|
||||
|
||||
val dataLen = packet.size - 2 // 减去CRC16的2字节
|
||||
val calculatedCrc = crc16(packet, 0, dataLen)
|
||||
val packetCrc = ((packet[packet.size - 1].toInt() and 0xFF) shl 8) or
|
||||
(packet[packet.size - 2].toInt() and 0xFF)
|
||||
|
||||
return calculatedCrc == packetCrc
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PPTM数据包
|
||||
* @param packet 完整数据包
|
||||
* @return 解析结果 (操作码, 负载数据) 或 null
|
||||
*/
|
||||
fun parsePacket(packet: ByteArray): Pair<Short, ByteArray>? {
|
||||
if (!verifyCrc16(packet)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (packet.size < 6) return null
|
||||
|
||||
val buf = ByteBuffer.wrap(packet)
|
||||
buf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
val opcode = buf.short
|
||||
val payloadLen = buf.short
|
||||
|
||||
if (packet.size < 4 + payloadLen + 2) return null // 操作码(2) + 长度(2) + 负载 + CRC(2)
|
||||
|
||||
val payload = ByteArray(payloadLen.toInt())
|
||||
buf.get(payload)
|
||||
|
||||
return Pair(opcode, payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.View
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
* 真正的生产者-消费者模式图表视图
|
||||
*
|
||||
* 关键设计特点:
|
||||
* 1. 生产者-消费者模式:蓝牙回调生产数据,定时器消费数据
|
||||
* 2. 批量处理:减少频繁的UI更新,提高性能
|
||||
* 3. 自动滚动:数据超出屏幕宽度时自动滚动显示最新数据
|
||||
* 4. 多通道支持:每个通道独立的数据列表和绘制路径
|
||||
* 5. 内存控制:自动清理旧数据,防止内存溢出
|
||||
*/
|
||||
class ProducerConsumerChartView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
// ==================== 生产者-消费者模式核心 ====================
|
||||
|
||||
// 生产者:蓝牙数据回调线程
|
||||
private val dataQueue = LinkedBlockingQueue<List<Float>>()
|
||||
private val multiChannelDataQueue = LinkedBlockingQueue<List<List<Float>>>()
|
||||
|
||||
// 消费者:定时器线程
|
||||
private val displayExecutor = ScheduledThreadPoolExecutor(1) { r ->
|
||||
Thread(r, "ChartDisplayThread").apply { isDaemon = true }
|
||||
}
|
||||
private var displayTask: ScheduledFuture<*>? = null
|
||||
|
||||
// 消费者控制
|
||||
private val isConsuming = AtomicBoolean(false)
|
||||
// 修复 'val' cannot be reassigned 错误
|
||||
// 将 val 改为 var
|
||||
private var displayIntervalMs = 16L // 60fps = 16ms间隔
|
||||
|
||||
// ==================== 数据缓冲区 ====================
|
||||
|
||||
// 单通道数据缓冲区
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
private val maxBufferSize = 2000
|
||||
|
||||
// 多通道数据缓冲区
|
||||
private val multiChannelBuffer = mutableListOf<MutableList<Float>>()
|
||||
private var isMultiChannelMode = false
|
||||
private val channelColors = listOf(Color.RED, Color.BLUE, Color.GREEN, Color.YELLOW)
|
||||
|
||||
// ==================== 显示控制 ====================
|
||||
|
||||
private var lastDisplayTime = 0L
|
||||
private var pendingDataCount = 0
|
||||
private var totalDataProduced = 0L
|
||||
private var totalDataConsumed = 0L
|
||||
|
||||
// ==================== 图表配置 ====================
|
||||
|
||||
private var chartTitle = "通道"
|
||||
private var channelIndex = 0
|
||||
private var deviceType = "未知设备"
|
||||
private var sampleRate = 250f
|
||||
private var timeWindow = 4f
|
||||
private var maxDataPoints = 1000
|
||||
|
||||
// ==================== 绘制相关 ====================
|
||||
|
||||
private val paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
strokeWidth = 2f
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = 32f
|
||||
color = Color.WHITE
|
||||
}
|
||||
|
||||
private val backgroundPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.GRAY
|
||||
strokeWidth = 1f
|
||||
alpha = 100
|
||||
}
|
||||
|
||||
// 数据范围
|
||||
private var minValue = -1000f
|
||||
private var maxValue = 1000f
|
||||
private var dataRange = maxValue - minValue
|
||||
|
||||
// ==================== 交互控制 ====================
|
||||
|
||||
private var scaleFactor = 1.0f
|
||||
private var translateX = 0f
|
||||
private var translateY = 0f
|
||||
private var viewportStart = 0f
|
||||
private var viewportEnd = 1.0f
|
||||
private var isDragging = false
|
||||
private var lastTouchX = 0f
|
||||
private var lastTouchY = 0f
|
||||
|
||||
private val scaleGestureDetector = ScaleGestureDetector(context, ScaleListener())
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
init {
|
||||
Log.d("ProducerConsumerChart", "初始化生产者-消费者图表视图")
|
||||
startConsumer()
|
||||
}
|
||||
|
||||
// ==================== 生产者-消费者模式实现 ====================
|
||||
|
||||
/**
|
||||
* 启动消费者线程(定时器)
|
||||
*/
|
||||
private fun startConsumer() {
|
||||
if (isConsuming.get()) {
|
||||
Log.w("ProducerConsumerChart", "消费者已在运行")
|
||||
return
|
||||
}
|
||||
|
||||
isConsuming.set(true)
|
||||
displayTask = displayExecutor.scheduleAtFixedRate({
|
||||
try {
|
||||
consumeData()
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProducerConsumerChart", "消费者异常: ${e.message}", e)
|
||||
}
|
||||
}, 0, displayIntervalMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
Log.d("ProducerConsumerChart", "消费者线程已启动,刷新间隔: ${displayIntervalMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止消费者线程
|
||||
*/
|
||||
private fun stopConsumer() {
|
||||
isConsuming.set(false)
|
||||
displayTask?.cancel(false)
|
||||
displayTask = null
|
||||
Log.d("ProducerConsumerChart", "消费者线程已停止")
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产者:接收蓝牙数据
|
||||
*/
|
||||
fun produceData(newData: List<Float>) {
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
// 生产者:将数据放入队列
|
||||
val success = dataQueue.offer(newData)
|
||||
if (success) {
|
||||
totalDataProduced += newData.size
|
||||
Log.d("ProducerConsumerChart", "生产者:数据已入队 ${newData.size}个点,队列大小: ${dataQueue.size}")
|
||||
} else {
|
||||
Log.w("ProducerConsumerChart", "生产者:队列已满,丢弃数据 ${newData.size}个点")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产者:接收多通道数据
|
||||
*/
|
||||
fun produceMultiChannelData(channelData: List<List<Float>>) {
|
||||
if (channelData.isEmpty()) return
|
||||
|
||||
// 生产者:将数据放入队列
|
||||
val success = multiChannelDataQueue.offer(channelData)
|
||||
if (success) {
|
||||
totalDataProduced += channelData.sumOf { it.size }
|
||||
Log.d("ProducerConsumerChart", "生产者:多通道数据已入队 ${channelData.size}个通道,队列大小: ${multiChannelDataQueue.size}")
|
||||
} else {
|
||||
Log.w("ProducerConsumerChart", "生产者:多通道队列已满,丢弃数据")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费者:定时消费数据并更新显示
|
||||
*/
|
||||
private fun consumeData() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
var hasNewData = false
|
||||
|
||||
// 批量消费单通道数据
|
||||
val singleChannelBatch = mutableListOf<List<Float>>()
|
||||
dataQueue.drainTo(singleChannelBatch, 10) // 每次最多消费10个数据包
|
||||
|
||||
if (singleChannelBatch.isNotEmpty()) {
|
||||
isMultiChannelMode = false
|
||||
for (data in singleChannelBatch) {
|
||||
dataBuffer.addAll(data)
|
||||
totalDataConsumed += data.size
|
||||
hasNewData = true
|
||||
}
|
||||
|
||||
// 控制缓冲区大小
|
||||
if (dataBuffer.size > maxBufferSize) {
|
||||
val excess = dataBuffer.size - maxBufferSize
|
||||
repeat(excess) {
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataBuffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("ProducerConsumerChart", "消费者:清理了 $excess 个旧数据点")
|
||||
}
|
||||
}
|
||||
|
||||
// 批量消费多通道数据
|
||||
val multiChannelBatch = mutableListOf<List<List<Float>>>()
|
||||
multiChannelDataQueue.drainTo(multiChannelBatch, 5) // 每次最多消费5个多通道数据包
|
||||
|
||||
if (multiChannelBatch.isNotEmpty()) {
|
||||
isMultiChannelMode = true
|
||||
for (channelData in multiChannelBatch) {
|
||||
// 确保多通道缓冲区有足够的通道
|
||||
while (multiChannelBuffer.size < channelData.size) {
|
||||
multiChannelBuffer.add(mutableListOf())
|
||||
}
|
||||
|
||||
// 添加数据到各通道
|
||||
channelData.forEachIndexed { index, data ->
|
||||
if (index < multiChannelBuffer.size) {
|
||||
multiChannelBuffer[index].addAll(data)
|
||||
totalDataConsumed += data.size
|
||||
hasNewData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 控制各通道缓冲区大小
|
||||
multiChannelBuffer.forEach { buffer ->
|
||||
if (buffer.size > maxBufferSize) {
|
||||
val excess = buffer.size - maxBufferSize
|
||||
repeat(excess) {
|
||||
if (buffer.isNotEmpty()) {
|
||||
buffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有新数据,触发UI更新
|
||||
if (hasNewData) {
|
||||
postInvalidate() // 在主线程更新UI
|
||||
Log.d("ProducerConsumerChart", "消费者:批量处理完成,单通道队列: ${dataQueue.size}, 多通道队列: ${multiChannelDataQueue.size}")
|
||||
}
|
||||
|
||||
// 定期打印统计信息
|
||||
if (currentTime - lastDisplayTime > 5000) {
|
||||
Log.d("ProducerConsumerChart", "统计 - 生产: $totalDataProduced, 消费: $totalDataConsumed, 队列: ${dataQueue.size + multiChannelDataQueue.size}")
|
||||
lastDisplayTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图表配置 ====================
|
||||
|
||||
fun setChartConfig(
|
||||
title: String,
|
||||
channelIdx: Int,
|
||||
device: String,
|
||||
maxPoints: Int,
|
||||
sampleRateHz: Float,
|
||||
timeWindowSec: Float
|
||||
) {
|
||||
chartTitle = title
|
||||
channelIndex = channelIdx
|
||||
deviceType = device
|
||||
maxDataPoints = maxPoints
|
||||
sampleRate = sampleRateHz
|
||||
timeWindow = timeWindowSec
|
||||
|
||||
Log.d("ProducerConsumerChart", "图表配置: $title, 设备: $device, 采样率: ${sampleRateHz}Hz")
|
||||
}
|
||||
|
||||
// ==================== 绘制方法 ====================
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 绘制背景
|
||||
canvas.drawRect(0f, 0f, width, height, backgroundPaint)
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制数据
|
||||
if (isMultiChannelMode) {
|
||||
drawMultiChannelData(canvas, width, height)
|
||||
} else {
|
||||
drawSingleChannelData(canvas, width, height)
|
||||
}
|
||||
|
||||
// 绘制标题和统计信息
|
||||
drawInfo(canvas, width, height)
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
val gridSpacing = 50f
|
||||
|
||||
// 垂直线
|
||||
var x = 0f
|
||||
while (x <= width) {
|
||||
canvas.drawLine(x, 0f, x, height, gridPaint)
|
||||
x += gridSpacing
|
||||
}
|
||||
|
||||
// 水平线
|
||||
var y = 0f
|
||||
while (y <= height) {
|
||||
canvas.drawLine(0f, y, width, y, gridPaint)
|
||||
y += gridSpacing
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawSingleChannelData(canvas: Canvas, width: Float, height: Float) {
|
||||
if (dataBuffer.isEmpty()) return
|
||||
|
||||
val path = Path()
|
||||
val dataCount = dataBuffer.size
|
||||
val stepX = width / maxOf(1, dataCount - 1)
|
||||
|
||||
// 计算Y轴缩放
|
||||
val centerY = height / 2
|
||||
val scaleY = height / dataRange
|
||||
|
||||
// 绘制数据线
|
||||
paint.color = Color.CYAN
|
||||
paint.strokeWidth = 2f
|
||||
|
||||
var isFirstPoint = true
|
||||
dataBuffer.forEachIndexed { index, value ->
|
||||
val x = index * stepX
|
||||
val y = centerY - (value - (minValue + maxValue) / 2) * scaleY
|
||||
|
||||
if (isFirstPoint) {
|
||||
path.moveTo(x, y)
|
||||
isFirstPoint = false
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
private fun drawMultiChannelData(canvas: Canvas, width: Float, height: Float) {
|
||||
if (multiChannelBuffer.isEmpty()) return
|
||||
|
||||
val channelHeight = height / multiChannelBuffer.size
|
||||
|
||||
multiChannelBuffer.forEachIndexed { channelIndex, channelData ->
|
||||
if (channelData.isEmpty()) return@forEachIndexed
|
||||
|
||||
val path = Path()
|
||||
val dataCount = channelData.size
|
||||
val stepX = width / maxOf(1, dataCount - 1)
|
||||
|
||||
// 计算Y轴缩放
|
||||
val centerY = channelHeight * (channelIndex + 0.5f)
|
||||
val scaleY = channelHeight / dataRange
|
||||
|
||||
// 绘制数据线
|
||||
paint.color = channelColors[channelIndex % channelColors.size]
|
||||
paint.strokeWidth = 2f
|
||||
|
||||
var isFirstPoint = true
|
||||
channelData.forEachIndexed { index, value ->
|
||||
val x = index * stepX
|
||||
val y = centerY - (value - (minValue + maxValue) / 2) * scaleY
|
||||
|
||||
if (isFirstPoint) {
|
||||
path.moveTo(x, y)
|
||||
isFirstPoint = false
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawInfo(canvas: Canvas, width: Float, height: Float) {
|
||||
val info = buildString {
|
||||
appendLine("$chartTitle (通道$channelIndex)")
|
||||
appendLine("设备: $deviceType")
|
||||
appendLine("采样率: ${sampleRate}Hz")
|
||||
appendLine("数据点: ${if (isMultiChannelMode) multiChannelBuffer.sumOf { it.size } else dataBuffer.size}")
|
||||
appendLine("队列: ${dataQueue.size + multiChannelDataQueue.size}")
|
||||
appendLine("生产: $totalDataProduced, 消费: $totalDataConsumed")
|
||||
}
|
||||
|
||||
val lines = info.trim().split("\n")
|
||||
var y = 40f
|
||||
lines.forEach { line ->
|
||||
canvas.drawText(line, 20f, y, textPaint)
|
||||
y += 35f
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 交互处理 ====================
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isDragging = true
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (isDragging) {
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
|
||||
translateX += deltaX
|
||||
translateY += deltaY
|
||||
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
scaleFactor *= detector.scaleFactor
|
||||
scaleFactor = scaleFactor.coerceIn(0.1f, 10.0f)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期管理 ====================
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
stopConsumer()
|
||||
displayExecutor.shutdown()
|
||||
Log.d("ProducerConsumerChart", "视图已销毁,消费者线程已停止")
|
||||
}
|
||||
|
||||
// ==================== 公共接口 ====================
|
||||
|
||||
fun setDisplayRefreshRate(frequencyHz: Int) {
|
||||
val newInterval = 1000L / frequencyHz
|
||||
if (newInterval != displayIntervalMs) {
|
||||
stopConsumer()
|
||||
displayIntervalMs = newInterval
|
||||
startConsumer()
|
||||
Log.d("ProducerConsumerChart", "显示刷新频率已调整为: ${frequencyHz}Hz (${displayIntervalMs}ms间隔)")
|
||||
}
|
||||
}
|
||||
|
||||
fun getQueueStatus(): String {
|
||||
return "单通道队列: ${dataQueue.size}, 多通道队列: ${multiChannelDataQueue.size}, 生产: $totalDataProduced, 消费: $totalDataConsumed"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.util.Log
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 信号处理使用示例
|
||||
* 展示如何在现有流式数据读取代码结构中使用信号处理功能
|
||||
*/
|
||||
class SignalProcessingExample(private val dataManager: DataManager) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SignalProcessingExample"
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例1:实时信号质量监控
|
||||
* 在流式数据处理过程中实时监控信号质量
|
||||
*/
|
||||
fun demonstrateRealTimeQualityMonitoring() {
|
||||
Log.d(TAG, "=== 开始实时信号质量监控演示 ===")
|
||||
|
||||
val packets = dataManager.getPacketBuffer()
|
||||
if (packets.isEmpty()) {
|
||||
Log.w(TAG, "没有数据包可供监控")
|
||||
return
|
||||
}
|
||||
|
||||
// 监控前10个数据包的质量
|
||||
val packetsToMonitor = packets.take(10)
|
||||
var totalQuality = 0.0f
|
||||
var validPackets = 0
|
||||
|
||||
for ((index, packet) in packetsToMonitor.withIndex()) {
|
||||
val quality = dataManager.calculateSignalQuality(packet)
|
||||
if (quality > 0) {
|
||||
totalQuality += quality
|
||||
validPackets++
|
||||
Log.d(TAG, "数据包 $index (${packet.dataType}): 质量 = $quality")
|
||||
}
|
||||
}
|
||||
|
||||
if (validPackets > 0) {
|
||||
val averageQuality = totalQuality / validPackets
|
||||
Log.d(TAG, "平均信号质量: $averageQuality")
|
||||
|
||||
// 根据质量决定是否需要调整滤波器参数
|
||||
if (averageQuality < 0.5f) {
|
||||
Log.w(TAG, "信号质量较低,建议检查传感器连接或调整滤波器参数")
|
||||
} else if (averageQuality > 0.8f) {
|
||||
Log.i(TAG, "信号质量良好")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例2:按设备类型应用不同的信号处理策略
|
||||
*/
|
||||
fun demonstrateDeviceSpecificProcessing() {
|
||||
Log.d(TAG, "=== 开始设备特定信号处理演示 ===")
|
||||
|
||||
val packets = dataManager.getPacketBuffer()
|
||||
if (packets.isEmpty()) {
|
||||
Log.w(TAG, "没有数据包可供处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 按设备类型分组
|
||||
val packetsByType = packets.groupBy { it.dataType }
|
||||
|
||||
for ((dataType, typePackets) in packetsByType) {
|
||||
Log.d(TAG, "处理 ${dataType.name} 类型数据,共 ${typePackets.size} 个数据包")
|
||||
|
||||
when (dataType) {
|
||||
SensorData.DataType.EEG -> {
|
||||
Log.d(TAG, "应用EEG专用滤波器:带通滤波(1-40Hz) + 幅度归一化")
|
||||
}
|
||||
SensorData.DataType.ECG_2LEAD, SensorData.DataType.ECG_12LEAD -> {
|
||||
Log.d(TAG, "应用ECG专用滤波器:高通(0.5Hz) + 低通(40Hz) + 陷波(50Hz)")
|
||||
}
|
||||
SensorData.DataType.PPG -> {
|
||||
Log.d(TAG, "应用PPG专用滤波器:低通(8Hz) + 运动伪影去除")
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "数据类型 ${dataType.name} 暂不支持专用处理")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例3:批量信号处理
|
||||
* 对大量数据进行批量信号处理
|
||||
*/
|
||||
fun demonstrateBatchProcessing() {
|
||||
Log.d(TAG, "=== 开始批量信号处理演示 ===")
|
||||
|
||||
val packets = dataManager.getPacketBuffer()
|
||||
if (packets.isEmpty()) {
|
||||
Log.w(TAG, "没有数据包可供批量处理")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "开始批量处理 ${packets.size} 个数据包...")
|
||||
|
||||
// 记录处理开始时间
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 应用信号处理
|
||||
val processedPackets = dataManager.applySignalProcessing(packets)
|
||||
|
||||
// 记录处理结束时间
|
||||
val endTime = System.currentTimeMillis()
|
||||
val processingTime = endTime - startTime
|
||||
|
||||
Log.d(TAG, "批量处理完成!")
|
||||
Log.d(TAG, "处理时间: ${processingTime}ms")
|
||||
Log.d(TAG, "处理数据包: ${processedPackets.size} 个")
|
||||
Log.d(TAG, "平均处理速度: ${packets.size * 1000.0 / processingTime} 包/秒")
|
||||
|
||||
// 显示处理后的信号质量统计
|
||||
if (processedPackets.isNotEmpty()) {
|
||||
val qualityScores = mutableListOf<Float>()
|
||||
for (packet in processedPackets.take(20)) { // 只检查前20个包
|
||||
val quality = dataManager.calculateSignalQuality(packet)
|
||||
if (quality > 0) {
|
||||
qualityScores.add(quality)
|
||||
}
|
||||
}
|
||||
|
||||
if (qualityScores.isNotEmpty()) {
|
||||
val avgQuality = qualityScores.average()
|
||||
val maxQuality = qualityScores.maxOrNull() ?: 0f
|
||||
val minQuality = qualityScores.minOrNull() ?: 0f
|
||||
|
||||
Log.d(TAG, "处理后信号质量统计:")
|
||||
Log.d(TAG, " 平均质量: ${String.format("%.3f", avgQuality)}")
|
||||
Log.d(TAG, " 最高质量: ${String.format("%.3f", maxQuality)}")
|
||||
Log.d(TAG, " 最低质量: ${String.format("%.3f", minQuality)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例4:自适应滤波器参数调整
|
||||
* 根据信号质量自动调整滤波器参数
|
||||
*/
|
||||
fun demonstrateAdaptiveFilterAdjustment() {
|
||||
Log.d(TAG, "=== 开始自适应滤波器参数调整演示 ===")
|
||||
|
||||
val packets = dataManager.getPacketBuffer()
|
||||
if (packets.isEmpty()) {
|
||||
Log.w(TAG, "没有数据包可供自适应处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取第一个数据包进行质量评估
|
||||
val firstPacket = packets.first()
|
||||
val initialQuality = dataManager.calculateSignalQuality(firstPacket)
|
||||
|
||||
Log.d(TAG, "初始信号质量: $initialQuality")
|
||||
|
||||
if (initialQuality < 0.3f) {
|
||||
Log.w(TAG, "信号质量过低,建议检查硬件连接")
|
||||
} else if (initialQuality < 0.6f) {
|
||||
Log.i(TAG, "信号质量一般,可以尝试调整滤波器参数")
|
||||
|
||||
// 这里可以添加自适应参数调整逻辑
|
||||
// 例如:根据噪声水平调整截止频率
|
||||
when (firstPacket.dataType) {
|
||||
SensorData.DataType.EEG -> {
|
||||
Log.d(TAG, "建议调整EEG滤波器:降低高通截止频率到0.5Hz,提高低通截止频率到50Hz")
|
||||
}
|
||||
SensorData.DataType.ECG_2LEAD, SensorData.DataType.ECG_12LEAD -> {
|
||||
Log.d(TAG, "建议调整ECG滤波器:提高高通截止频率到1Hz,降低低通截止频率到30Hz")
|
||||
}
|
||||
SensorData.DataType.PPG -> {
|
||||
Log.d(TAG, "建议调整PPG滤波器:降低低通截止频率到6Hz")
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "该数据类型暂不支持自适应调整")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "信号质量良好,当前滤波器参数合适")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行所有演示
|
||||
*/
|
||||
fun runAllDemonstrations() {
|
||||
Log.d(TAG, "开始运行所有信号处理演示...")
|
||||
|
||||
try {
|
||||
demonstrateRealTimeQualityMonitoring()
|
||||
demonstrateDeviceSpecificProcessing()
|
||||
demonstrateBatchProcessing()
|
||||
demonstrateAdaptiveFilterAdjustment()
|
||||
|
||||
Log.d(TAG, "所有演示完成!")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "演示过程中发生错误", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.util.Log
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 信号处理使用示例类
|
||||
* 展示如何使用JNI信号处理功能
|
||||
*/
|
||||
class SignalProcessorExample {
|
||||
|
||||
private val signalProcessor = SignalProcessorJNI()
|
||||
private val sampleRate = 1000.0 // 1kHz采样率
|
||||
|
||||
init {
|
||||
// 初始化信号处理器
|
||||
if (!signalProcessor.createProcessor()) {
|
||||
Log.e("SignalProcessorExample", "Failed to create signal processor")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试信号:正弦波 + 噪声
|
||||
*/
|
||||
fun generateTestSignal(frequency: Double, duration: Double, noiseLevel: Double = 0.1): FloatArray {
|
||||
val numSamples = (sampleRate * duration).toInt()
|
||||
val signal = FloatArray(numSamples)
|
||||
|
||||
for (i in 0 until numSamples) {
|
||||
val time = i / sampleRate
|
||||
val sineWave = sin(2 * PI * frequency * time).toFloat()
|
||||
val noise = (Math.random() * 2 - 1).toFloat() * noiseLevel
|
||||
signal[i] = (sineWave + noise).toFloat()
|
||||
}
|
||||
|
||||
return signal
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示带通滤波
|
||||
*/
|
||||
fun demonstrateBandpassFilter() {
|
||||
Log.d("SignalProcessorExample", "=== 带通滤波演示 ===")
|
||||
|
||||
// 生成包含多个频率的测试信号
|
||||
val signal = generateTestSignal(50.0, 1.0, 0.2) // 50Hz + 噪声
|
||||
|
||||
// 应用带通滤波 (40-60Hz)
|
||||
val filteredSignal = signalProcessor.bandpassFilter(signal, sampleRate, 40.0, 60.0)
|
||||
|
||||
if (filteredSignal != null) {
|
||||
Log.d("SignalProcessorExample", "滤波成功!原始信号长度: ${signal.size}, 滤波后长度: ${filteredSignal.size}")
|
||||
|
||||
// 计算信号质量
|
||||
val originalQuality = signalProcessor.calculateSignalQuality(signal)
|
||||
val filteredQuality = signalProcessor.calculateSignalQuality(filteredSignal)
|
||||
|
||||
Log.d("SignalProcessorExample", "原始信号质量: $originalQuality")
|
||||
Log.d("SignalProcessorExample", "滤波后信号质量: $filteredQuality")
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "带通滤波失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示低通滤波
|
||||
*/
|
||||
fun demonstrateLowpassFilter() {
|
||||
Log.d("SignalProcessorExample", "=== 低通滤波演示 ===")
|
||||
|
||||
// 生成高频信号
|
||||
val signal = generateTestSignal(200.0, 1.0, 0.1)
|
||||
|
||||
// 应用低通滤波 (100Hz截止)
|
||||
val filteredSignal = signalProcessor.lowpassFilter(signal, sampleRate, 100.0)
|
||||
|
||||
if (filteredSignal != null) {
|
||||
Log.d("SignalProcessorExample", "低通滤波成功!")
|
||||
|
||||
// 计算信号质量
|
||||
val originalQuality = signalProcessor.calculateSignalQuality(signal)
|
||||
val filteredQuality = signalProcessor.calculateSignalQuality(filteredSignal)
|
||||
|
||||
Log.d("SignalProcessorExample", "原始信号质量: $originalQuality")
|
||||
Log.d("SignalProcessorExample", "滤波后信号质量: $filteredQuality")
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "低通滤波失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示高通滤波
|
||||
*/
|
||||
fun demonstrateHighpassFilter() {
|
||||
Log.d("SignalProcessorExample", "=== 高通滤波演示 ===")
|
||||
|
||||
// 生成包含低频和高频的信号
|
||||
val signal = generateTestSignal(10.0, 1.0, 0.1) // 10Hz低频信号
|
||||
|
||||
// 应用高通滤波 (50Hz截止)
|
||||
val filteredSignal = signalProcessor.highpassFilter(signal, sampleRate, 50.0)
|
||||
|
||||
if (filteredSignal != null) {
|
||||
Log.d("SignalProcessorExample", "高通滤波成功!")
|
||||
|
||||
// 计算信号质量
|
||||
val originalQuality = signalProcessor.calculateSignalQuality(signal)
|
||||
val filteredQuality = signalProcessor.calculateSignalQuality(filteredSignal)
|
||||
|
||||
Log.d("SignalProcessorExample", "原始信号质量: $originalQuality")
|
||||
Log.d("SignalProcessorExample", "滤波后信号质量: $filteredQuality")
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "高通滤波失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示陷波滤波(去除工频干扰)
|
||||
*/
|
||||
fun demonstrateNotchFilter() {
|
||||
Log.d("SignalProcessorExample", "=== 陷波滤波演示 ===")
|
||||
|
||||
// 生成包含工频干扰的信号
|
||||
val signal = generateTestSignal(50.0, 1.0, 0.3) // 50Hz工频 + 噪声
|
||||
|
||||
// 应用陷波滤波 (50Hz陷波)
|
||||
val filteredSignal = signalProcessor.notchFilter(signal, sampleRate, 50.0, 30.0)
|
||||
|
||||
if (filteredSignal != null) {
|
||||
Log.d("SignalProcessorExample", "陷波滤波成功!")
|
||||
|
||||
// 计算信号质量
|
||||
val originalQuality = signalProcessor.calculateSignalQuality(signal)
|
||||
val filteredQuality = signalProcessor.calculateSignalQuality(filteredSignal)
|
||||
|
||||
Log.d("SignalProcessorExample", "原始信号质量: $originalQuality")
|
||||
Log.d("SignalProcessorExample", "滤波后信号质量: $filteredQuality")
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "陷波滤波失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示ECG信号质量评估
|
||||
*/
|
||||
fun demonstrateECGSQI() {
|
||||
Log.d("SignalProcessorExample", "=== ECG信号质量评估演示 ===")
|
||||
|
||||
// 生成模拟ECG信号
|
||||
val ecgSignal = generateTestSignal(1.0, 2.0, 0.05) // 1Hz心跳 + 低噪声
|
||||
|
||||
// 计算ECG信号质量指数
|
||||
val sqi = signalProcessor.calculateECGSQI(ecgSignal, sampleRate)
|
||||
|
||||
Log.d("SignalProcessorExample", "ECG信号质量指数: $sqi")
|
||||
|
||||
if (sqi > 0.7f) {
|
||||
Log.d("SignalProcessorExample", "ECG信号质量良好")
|
||||
} else if (sqi > 0.4f) {
|
||||
Log.d("SignalProcessorExample", "ECG信号质量一般")
|
||||
} else {
|
||||
Log.d("SignalProcessorExample", "ECG信号质量较差")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示信号特征提取
|
||||
*/
|
||||
fun demonstrateFeatureExtraction() {
|
||||
Log.d("SignalProcessorExample", "=== 信号特征提取演示 ===")
|
||||
|
||||
// 生成测试信号
|
||||
val signal = generateTestSignal(100.0, 0.5, 0.15)
|
||||
|
||||
// 提取特征
|
||||
val features = signalProcessor.extractFeatures(signal, sampleRate)
|
||||
|
||||
if (features != null) {
|
||||
Log.d("SignalProcessorExample", "特征提取成功!特征数量: ${features.size}")
|
||||
|
||||
// 显示前几个特征值
|
||||
for (i in 0 until minOf(5, features.size)) {
|
||||
Log.d("SignalProcessorExample", "特征 $i: ${features[i]}")
|
||||
}
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "特征提取失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示信号归一化
|
||||
*/
|
||||
fun demonstrateNormalization() {
|
||||
Log.d("SignalProcessorExample", "=== 信号归一化演示 ===")
|
||||
|
||||
// 生成幅度较大的信号
|
||||
val signal = generateTestSignal(75.0, 0.5, 0.2)
|
||||
|
||||
// 找到最大绝对值
|
||||
val maxAbs = signal.maxOfOrNull { kotlin.math.abs(it) } ?: 0f
|
||||
Log.d("SignalProcessorExample", "归一化前最大绝对值: $maxAbs")
|
||||
|
||||
// 归一化
|
||||
signalProcessor.normalizeAmplitude(signal)
|
||||
|
||||
// 检查归一化结果
|
||||
val normalizedMaxAbs = signal.maxOfOrNull { kotlin.math.abs(it) } ?: 0f
|
||||
Log.d("SignalProcessorExample", "归一化后最大绝对值: $normalizedMaxAbs")
|
||||
|
||||
if (kotlin.math.abs(normalizedMaxAbs - 1.0f) < 0.01f) {
|
||||
Log.d("SignalProcessorExample", "归一化成功!")
|
||||
} else {
|
||||
Log.e("SignalProcessorExample", "归一化失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示相关性计算
|
||||
*/
|
||||
fun demonstrateCorrelation() {
|
||||
Log.d("SignalProcessorExample", "=== 相关性计算演示 ===")
|
||||
|
||||
// 生成两个相关信号
|
||||
val signal1 = generateTestSignal(50.0, 1.0, 0.1)
|
||||
val signal2 = generateTestSignal(50.0, 1.0, 0.1) // 相同频率
|
||||
|
||||
// 计算相关性
|
||||
val correlation = signalProcessor.calculateCorrelation(signal1, signal2)
|
||||
|
||||
Log.d("SignalProcessorExample", "信号相关性: $correlation")
|
||||
|
||||
if (correlation > 0.8f) {
|
||||
Log.d("SignalProcessorExample", "信号高度相关")
|
||||
} else if (correlation > 0.5f) {
|
||||
Log.d("SignalProcessorExample", "信号中等相关")
|
||||
} else {
|
||||
Log.d("SignalProcessorExample", "信号相关性较低")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行所有演示
|
||||
*/
|
||||
fun runAllDemonstrations() {
|
||||
Log.d("SignalProcessorExample", "开始运行所有信号处理演示...")
|
||||
|
||||
try {
|
||||
demonstrateBandpassFilter()
|
||||
demonstrateLowpassFilter()
|
||||
demonstrateHighpassFilter()
|
||||
demonstrateNotchFilter()
|
||||
demonstrateECGSQI()
|
||||
demonstrateFeatureExtraction()
|
||||
demonstrateNormalization()
|
||||
demonstrateCorrelation()
|
||||
|
||||
Log.d("SignalProcessorExample", "所有演示完成!")
|
||||
} catch (e: Exception) {
|
||||
Log.e("SignalProcessorExample", "演示过程中发生错误", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
signalProcessor.destroyProcessor()
|
||||
Log.d("SignalProcessorExample", "信号处理器已销毁")
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,77 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||
*/
|
||||
class UiManager {
|
||||
|
||||
companion object {
|
||||
// UI更新相关
|
||||
const val UPDATE_INTERVAL = 200L // 优化:每200毫秒更新一次UI,减慢更新速度以便观察心电波形
|
||||
|
||||
// 显示限制相关
|
||||
const val MAX_DISPLAY_PACKETS = 5 // 最大显示数据包数量
|
||||
const val MAX_DETAIL_PACKETS = 3 // 最大详情数据包数量
|
||||
const val MAX_DISPLAY_CHANNELS = 4 // 最大显示通道数量
|
||||
const val MAX_12LEAD_CHANNELS = 6 // 12导联心电最大通道数量
|
||||
const val MAX_DISPLAY_SAMPLES = 750 // 最大显示采样点数量,调整为显示约3秒数据(基于250Hz采样率)
|
||||
|
||||
// 设备类型名称映射
|
||||
fun getDeviceName(dataType: SensorData.DataType): String {
|
||||
return when (dataType) {
|
||||
SensorData.DataType.EEG -> "脑电设备"
|
||||
SensorData.DataType.ECG_2LEAD -> "胸腹设备"
|
||||
SensorData.DataType.PPG -> "血氧设备"
|
||||
SensorData.DataType.ECG_12LEAD -> "12导联心电"
|
||||
SensorData.DataType.STETHOSCOPE -> "数字听诊"
|
||||
SensorData.DataType.SNORE -> "鼾声设备"
|
||||
SensorData.DataType.RESPIRATION -> "呼吸/姿态"
|
||||
SensorData.DataType.MIT_BIH -> "MIT-BIH"
|
||||
else -> "未知设备"
|
||||
}
|
||||
}
|
||||
|
||||
// 构建通道数据详情
|
||||
fun buildChannelDetails(
|
||||
data: List<SensorData>,
|
||||
maxPackets: Int = MAX_DETAIL_PACKETS,
|
||||
maxChannels: Int = MAX_DISPLAY_CHANNELS,
|
||||
maxSamples: Int = MAX_DISPLAY_SAMPLES
|
||||
): String {
|
||||
if (data.isEmpty()) {
|
||||
return "无通道数据"
|
||||
}
|
||||
|
||||
val details = mutableListOf<String>()
|
||||
|
||||
data.take(maxPackets).forEachIndexed { packetIndex, sensorData ->
|
||||
if (sensorData.channelData.isNullOrEmpty()) {
|
||||
details.add("数据包 ${packetIndex + 1}: 无通道数据")
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
details.add("数据包 ${packetIndex + 1} (${getDeviceName(sensorData.dataType ?: SensorData.DataType.EEG)}):")
|
||||
|
||||
sensorData.channelData.take(maxChannels).forEachIndexed { channelIndex, channel ->
|
||||
if (channel.isNullOrEmpty()) {
|
||||
details.add(" 通道 ${channelIndex + 1}: 空数据")
|
||||
return@forEachIndexed
|
||||
}
|
||||
val sampleCount = minOf(maxSamples, channel.size)
|
||||
val channelDataStr = channel.take(sampleCount).joinToString(", ") { "%.1f".format(it) }
|
||||
details.add(" 通道 ${channelIndex + 1}: ${sampleCount}/${channel.size} 采样点")
|
||||
details.add(" 示例: $channelDataStr${if (channel.size > sampleCount) "..." else ""}")
|
||||
}
|
||||
|
||||
if (sensorData.channelData.size > maxChannels) {
|
||||
details.add(" ... 还有 ${sensorData.channelData.size - maxChannels} 个通道")
|
||||
}
|
||||
details.add("")
|
||||
}
|
||||
|
||||
if (data.size > maxPackets) {
|
||||
details.add("... 还有 ${data.size - maxPackets} 个数据包")
|
||||
}
|
||||
return details.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private val uiUpdatePending = AtomicBoolean(false)
|
||||
private var lastUpdateTime = 0L
|
||||
|
||||
|
|
@ -21,7 +92,7 @@ class UiManager {
|
|||
updateCallback: () -> Unit
|
||||
) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastUpdateTime >= Constants.UPDATE_INTERVAL &&
|
||||
if (currentTime - lastUpdateTime >= UPDATE_INTERVAL &&
|
||||
uiUpdatePending.compareAndSet(false, true)) {
|
||||
lastUpdateTime = currentTime
|
||||
updateCallback()
|
||||
|
|
@ -45,7 +116,7 @@ class UiManager {
|
|||
if (packetBuffer.isNotEmpty()) {
|
||||
// 获取设备类型信息
|
||||
val deviceTypes = packetBuffer.mapNotNull { it.dataType }.distinct()
|
||||
val deviceInfo = deviceTypes.joinToString(", ") { DeviceTypeHelper.getDeviceName(it) }
|
||||
val deviceInfo = deviceTypes.joinToString(", ") { getDeviceName(it) }
|
||||
append("设备类型: $deviceInfo\n")
|
||||
} else {
|
||||
append("设备类型: 无\n")
|
||||
|
|
@ -82,22 +153,22 @@ class UiManager {
|
|||
val metricsInfo = buildMetricsInfo(calculatedMetrics)
|
||||
|
||||
// 只显示最新的一些数据包详情
|
||||
val recentPackets = packetBuffer.takeLast(Constants.MAX_DISPLAY_PACKETS)
|
||||
val recentPackets = packetBuffer.takeLast(MAX_DISPLAY_PACKETS)
|
||||
val has12Lead = recentPackets.any { it.dataType == SensorData.DataType.ECG_12LEAD }
|
||||
|
||||
val channelDetails = if (has12Lead) {
|
||||
DeviceTypeHelper.buildChannelDetails(
|
||||
buildChannelDetails(
|
||||
recentPackets,
|
||||
maxPackets = Constants.MAX_DETAIL_PACKETS,
|
||||
maxChannels = Constants.MAX_12LEAD_CHANNELS,
|
||||
maxSamples = Constants.MAX_DISPLAY_SAMPLES
|
||||
maxPackets = MAX_DETAIL_PACKETS,
|
||||
maxChannels = MAX_12LEAD_CHANNELS,
|
||||
maxSamples = MAX_DISPLAY_SAMPLES
|
||||
)
|
||||
} else {
|
||||
DeviceTypeHelper.buildChannelDetails(
|
||||
buildChannelDetails(
|
||||
recentPackets,
|
||||
maxPackets = Constants.MAX_DETAIL_PACKETS,
|
||||
maxChannels = Constants.MAX_DISPLAY_CHANNELS,
|
||||
maxSamples = Constants.MAX_DISPLAY_SAMPLES
|
||||
maxPackets = MAX_DETAIL_PACKETS,
|
||||
maxChannels = MAX_DISPLAY_CHANNELS,
|
||||
maxSamples = MAX_DISPLAY_SAMPLES
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +204,7 @@ class UiManager {
|
|||
val latestPacket = processedPackets.lastOrNull()
|
||||
if (latestPacket != null) {
|
||||
append("\n最新处理数据包:\n")
|
||||
append("- 数据类型: ${DeviceTypeHelper.getDeviceName(latestPacket.getDataType())}\n")
|
||||
append("- 数据类型: ${getDeviceName(latestPacket.getDataType())}\n")
|
||||
append("- 时间戳: ${latestPacket.getTimestamp()}\n")
|
||||
append("- 包序号: ${latestPacket.getPacketSn()}\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,418 @@
|
|||
轻迅蓝牙通信协议
|
||||
V1.0.0
|
||||
目录
|
||||
|
||||
1. 范围...................................................................................................................................................3
|
||||
2. 蓝牙接口定义 ................................................................................................................................ 3
|
||||
|
||||
2.1 服务接口 .......................................................................................................................................3
|
||||
2.2 广播接口 .......................................................................................................................................4
|
||||
3. 帧格式说明.....................................................................................................................................5
|
||||
3.1 帧数据包结构 ............................................................................................................................ 5
|
||||
3.2 功能码定义................................................................................................................................. 6
|
||||
3.3 功能码描述................................................................................................................................. 6
|
||||
3.4 数据标识定义 .......................................................................................................................... 10
|
||||
4. 通信逻辑说明 ..............................................................................................................................13
|
||||
4.1 丢包处理 ................................................................................................................................... 13
|
||||
4.2 压缩............................................................................................................................................ 13
|
||||
4.2 空中升级 ................................................................................................................................... 13
|
||||
1. 范围
|
||||
|
||||
本协议文档用于蓝牙主设备终端与轻迅采集设备之间蓝牙数据通信。
|
||||
|
||||
2. 蓝牙接口定义
|
||||
|
||||
2.1 服务接口
|
||||
|
||||
2.1.1 自定义服务
|
||||
|
||||
2.1.1.1 数据服务
|
||||
|
||||
采用自定义 128bit UUID
|
||||
UUID: 6e400001-b5a3-f393-e0a9-68716563686f
|
||||
包含 Write Characteristic 和 Notify Characteristic 两个子接口
|
||||
(1) RX:Write Characteristic(提供 BLE 主设备 向 BLE 从设备发送消息或取相应消息)
|
||||
UUID:6e400002-b5a3-f393-e0a9-68716563686f
|
||||
属性:Write/WriteNoRSP
|
||||
数据类型:字节
|
||||
数据长度:244(每包最大可传输数据)
|
||||
(2) TX:Notify Characteristic(提供给 BLE 从设备向 BLE 主设备 发送通知信息)
|
||||
UUID: 6e400003-b5a3-f393-e0a9-68716563686f
|
||||
属性: Notify
|
||||
数据类型:字节
|
||||
数据长度:244(每包最大可传输数据)
|
||||
|
||||
2.1.1.1 空中升级服务
|
||||
|
||||
采用 Nordic DFU 功能实现
|
||||
Service UUID: 0xFE59
|
||||
2.1.2 标准服务
|
||||
|
||||
2.1.2.1 设备信息服务
|
||||
|
||||
采用 Bluetooth SIG 标准规定 Device Information Service
|
||||
Service UUID:0x180A
|
||||
包含设备编号,软件和硬件版本等信息的特征值
|
||||
具体对应特征值 UUID 和属性可查看标准文档
|
||||
|
||||
2.1.2.2 电量服务
|
||||
|
||||
采用 Bluetooth SIG 标准规定 Battery Service
|
||||
Service UUID:0x180F
|
||||
对应特征值 UUID 和属性可查看标准文档
|
||||
|
||||
2.2 广播接口
|
||||
|
||||
2.2.1 广播数据数据格式介绍
|
||||
|
||||
2.2.2 广播包结构
|
||||
|
||||
广播数据段描述 类型 说明
|
||||
|
||||
设备 LE 物理连接标识 0x01 长度:0x02
|
||||
类型:0x01
|
||||
数据:0x06
|
||||
设备名称 0x09 长度:0x07
|
||||
类型:0x09
|
||||
数据:48 51 5F 42 45 45
|
||||
|
||||
设备名称视具体设备而定
|
||||
广播内容: 02 01 06 07 09 48 51 5F 42 45 45
|
||||
|
||||
2.2.3 扫描响应包结构
|
||||
|
||||
广播数据段描述 类型 说明
|
||||
厂商自定义数据 0xFF
|
||||
长度:0x1A
|
||||
类型:0xFF
|
||||
数据:
|
||||
|
||||
Company ID:2 字节
|
||||
Protocol Version:1 字节
|
||||
Device Code:2 字节
|
||||
MAC Address:6 字节
|
||||
|
||||
广播内容:0C FF 58 51 01 02 01 76 D8 57 F7 68 C2
|
||||
|
||||
Company ID 公司 ID 固定值为 0x5158
|
||||
Protocol Version 用于指示当前设备使用的协议版本 0x01
|
||||
|
||||
Device Code 包含产品类型(Device Type,高 8 位)与子类型(Sub Device Type,低 8
|
||||
位)
|
||||
|
||||
Device Type 产品类型与 Sub Device Type 产品子类型定义如下表:
|
||||
|
||||
产品类型 类型 ID 产品子类型 子类型 ID
|
||||
多导睡眠设备 0x42 胸腹模块 0x10
|
||||
腕部模块 0x20
|
||||
额头模块 0x30
|
||||
腿部模块 0x40
|
||||
|
||||
MAC Address 声明当前设备 mac 地址,用于兼容 IOS 设备无法获取 BLE mac 地址问题
|
||||
|
||||
3. 帧格式说明
|
||||
|
||||
3.1 帧数据包结构
|
||||
|
||||
序号 长度 字段 说明
|
||||
1 2 功能码
|
||||
2 2 数据长度 len
|
||||
|
||||
3~(2+len) len 数据
|
||||
|
||||
3+len 2 MIC 校验
|
||||
4+len
|
||||
|
||||
功能码:操作命令码。
|
||||
数据长度:本次传送数据的长度。
|
||||
MIC 校验:采用 CRC-16-CCITT-FALSE 校验,校验内容包括功能码、数据长度、数据 3
|
||||
个部分
|
||||
说明:除特殊说明外,协议默认为小端格式。
|
||||
|
||||
3.2 功能码定义
|
||||
|
||||
APP->BLE
|
||||
|
||||
序号 功能码 功能 备注
|
||||
1 0x0000 查询设备信息 获取当前设备设置参数
|
||||
2 0x0001 采集使能开关 0 或 1,指定时间点
|
||||
3 0x0002 电量查询 查询设备电量
|
||||
4 0x0003 电刺激开关 开启关闭不同类型的电刺激
|
||||
5 0x000A 工频滤波开关 开启关闭工频滤波算法
|
||||
6 0x0080 同步时间戳 64bit 毫秒时间戳
|
||||
|
||||
BLE->APP
|
||||
|
||||
序号 功能码 功能 备注
|
||||
1 0x8000 数据上传 上报采集数据
|
||||
2 0x8001 设备状态上报 异常状态上报
|
||||
3 0x8002 电量上报 设备主动上报电量
|
||||
|
||||
3.3 功能码描述
|
||||
|
||||
3.3.1 设备信息
|
||||
|
||||
功能码 0x0000
|
||||
主->从:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0000 功能码
|
||||
2 0x0000 数据长度
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
从->主:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0000 功能码
|
||||
2 0x0001 数据长度
|
||||
1 见下表格式
|
||||
Data
|
||||
2 检验
|
||||
CRC16
|
||||
|
||||
Data 格式:
|
||||
|
||||
Byte Bit 状态位
|
||||
采集使能
|
||||
0 0
|
||||
1~7 保留
|
||||
|
||||
3.3.2 采集使能
|
||||
|
||||
功能码 0x0001
|
||||
主->从:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0001 功能码
|
||||
2 0x0009 数据长度
|
||||
1 0或1 采集开关
|
||||
8 Timestamp 指定时间戳
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
从->主:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0001 功能码
|
||||
2 0x0001 数据长度
|
||||
采集开启状
|
||||
1 0或1
|
||||
态
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
Timestamp 为开启或关闭采集操作的指定时间戳(毫秒级),如果为 0 则代表立即执行操作。
|
||||
可用于多设备同步开启采集。
|
||||
|
||||
3.3.3 电量查询
|
||||
|
||||
功能码 0x0002
|
||||
主->从: 长度 字段 说明
|
||||
从->主: 2 0x0002 功能码
|
||||
2 0x0000 数据长度
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0002 功能码
|
||||
2 0x0001 数据长度
|
||||
1 percent 电量百分比
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
3.3.4 电刺激开关
|
||||
|
||||
功能码:0x0003
|
||||
|
||||
主->从: 长度 字段 说明
|
||||
从->主: 2 0x0003 功能码
|
||||
2 0x0001 数据长度
|
||||
开关状态
|
||||
1 0x00 or 0x10~0x1F 以及电刺
|
||||
激类型
|
||||
2 CRC16 检验
|
||||
|
||||
长度 字段 说明
|
||||
|
||||
2 0x0003 功能码
|
||||
|
||||
2 0x0001 数据长度
|
||||
|
||||
开关状态
|
||||
|
||||
1 0x00 or 0x10~0x1F 以及电刺
|
||||
|
||||
激类型
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
说明:
|
||||
|
||||
0x00 高四位如果为 0000 表示电刺激关闭。
|
||||
|
||||
0x10~0x1F 高四位如果为 0001 表示电刺激开启,低四位 0000~1111 表示开启 16 种不
|
||||
|
||||
同类型的电刺激。
|
||||
|
||||
从机接收到指令后,根据指令通过 SPI 开启或关闭电刺激后,回传数据给主机。
|
||||
3.3.5 工频滤波开关
|
||||
|
||||
功能码 0x000A
|
||||
主->从:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x000A 功能码
|
||||
2 0x0001 数据长度
|
||||
1 0或1 开关状态
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
从->主:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x000A 功能码
|
||||
2 0x0000 数据长度
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
用于开启关闭工频滤波。设备默认开启工频滤波,在需要时可以进行临时关闭和开启。设
|
||||
备重启后,开关状态不保存。
|
||||
|
||||
3.3.6 同步时间戳
|
||||
|
||||
功能码 0x0080
|
||||
主->从:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0080 功能码
|
||||
2 0x0008 数据长度
|
||||
8 Timestamp 毫秒时间戳
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
从->主:
|
||||
|
||||
长度 字段 说明
|
||||
2 0x0080 功能码
|
||||
2 0x0000 数据长度
|
||||
|
||||
2 CRC16 检验
|
||||
|
||||
Timestamp 为毫秒级时间戳。若主设备接入网络,则会提供 unix 时间戳,从设备可以用来
|
||||
获取当前时间。若没有接入网络,主设备提供的时间戳为开机后的时间,仅能用于操作指
|
||||
令同步,不能当作实际时间。
|
||||
3.3.7 数据上报
|
||||
|
||||
功能码 0x8000
|
||||
|
||||
从->主:
|
||||
|
||||
长度 字段 说明
|
||||
功能码
|
||||
2 0x8000 数据长度
|
||||
2 len 上报数据
|
||||
len Data 检验
|
||||
2
|
||||
CRC16
|
||||
|
||||
Data 格式
|
||||
|
||||
长度 字段 说明
|
||||
|
||||
2 SN 包序号
|
||||
|
||||
N 数据 1 TLD 格式
|
||||
M 数据 2 TLD 格式
|
||||
…
|
||||
…
|
||||
|
||||
采集数据采用 TLD 格式,数据按照[Type][Length][Data]的顺序排列组织。每包数据可以包含
|
||||
多个 TLD 组。
|
||||
|
||||
具体 TLD 数据定义参考 3.4 数据标识定义。
|
||||
|
||||
3.3.8 状态上报
|
||||
|
||||
当前协议暂不处理
|
||||
|
||||
3.4 数据标识定义
|
||||
|
||||
3.4.1 多导睡眠设备-胸腹模块数据定义
|
||||
|
||||
胸腹电信号数据 Length(2 Byte) Data
|
||||
Type(2 Byte)
|
||||
0x4211 232 uint8_t loff_state[2];
|
||||
|
||||
int16_t ecg1[25] ;
|
||||
|
||||
int16_t ecg2[25] ;
|
||||
|
||||
int16_t emg1[25] ;
|
||||
|
||||
int16_t emg2[25] ;
|
||||
|
||||
int16_t br_temperature[5] ;
|
||||
|
||||
int16_t br_impedance1[5] ;
|
||||
|
||||
int16_t br_impedance2[5] ;
|
||||
|
||||
采样率:
|
||||
口鼻呼吸气流温度:100Hz
|
||||
胸腹呼吸阻抗:100Hz
|
||||
心电:500Hz
|
||||
下颌肌电:500Hz
|
||||
|
||||
脱落状态: 每个 1294 对应一个字节
|
||||
胸腹鼾声数据 Length(2 Byte) Data
|
||||
Type(2 Byte) 232 int8_t snore[232] ;
|
||||
0x4212
|
||||
Length(2 Byte) 采样率:
|
||||
胸腹呼吸气压数据 232 鼾声:500Hz
|
||||
Type(2 Byte)
|
||||
0x4213 Data
|
||||
int16_t br_nose_pressure[114] ;
|
||||
uint16_t movement ;
|
||||
uint8_t posture ;
|
||||
uint8_t ambient;
|
||||
|
||||
采样率:
|
||||
鼻呼吸气流气压:100Hz
|
||||
体位,体动:1Hz
|
||||
环境光:1Hz
|
||||
|
||||
3.4.2 多导睡眠设备-腕部模块数据定义
|
||||
|
||||
Type(2 Byte) Length(2 Byte) Data
|
||||
0x4220 232 int16_t ppg_hr[58] ;
|
||||
int16_t ppg_spo2[58] ;
|
||||
3.4.3 多导睡眠设备-额头模块数据定义 采样率:25Hz
|
||||
|
||||
Type(2 Byte) Length(2 Byte) Data
|
||||
0x4230 232 uint8_t loff_state[2] ;
|
||||
int16_t eeg[6][14] ;
|
||||
3.4.4 多导睡眠设备-腿部模块数据定义 int16_t eog[2][14] ;
|
||||
uint8_t reserve[6] ;
|
||||
Type(2 Byte) Length(2 Byte)
|
||||
0x4240 232 采样率:500Hz
|
||||
|
||||
Data
|
||||
uint8_t loff_state[2];
|
||||
int16_t emg[115] ;
|
||||
采样率:500Hz
|
||||
|
||||
4. 通信逻辑说明
|
||||
4.1 丢包处理
|
||||
|
||||
当前协议暂不启用丢包处理
|
||||
|
||||
4.2 压缩
|
||||
|
||||
当前协议暂不启用压缩
|
||||
|
||||
4.2 空中升级
|
||||
|
||||
空中升级采用 Nordic DFU 方案,具体做法是 App 发送特定数据使设备进入 DFU 状态,之后
|
||||
App 重连特定 DFU 设备进行升级。具体可以参考 Nordic DFU 方案文档。
|
||||
|
||||
Loading…
Reference in New Issue