APP
This commit is contained in:
parent
a3be0bf6ca
commit
bd8fd53597
|
|
@ -2,6 +2,5 @@
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
<mapping directory="$PROJECT_DIR$/../../../SDK_APP(1)/SDK_APP" vcs="Git" />
|
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -565,22 +565,23 @@ std::vector<SensorData> parse_device_data(const std::vector<uint8_t>& file_data)
|
||||||
return final_results;
|
return final_results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRC16校验函数 (Modbus CRC16)
|
// CRC16校验函数 (CCITT CRC16)
|
||||||
uint16_t calculate_crc16(const uint8_t* data, size_t length) {
|
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++) {
|
for (size_t i = 0; i < length; i++) {
|
||||||
crc ^= data[i];
|
|
||||||
for (int j = 0; j < 8; j++) {
|
for (int j = 0; j < 8; j++) {
|
||||||
if (crc & 0x0001) {
|
bool bit = ((data[i] >> (7 - j)) & 1) == 1;
|
||||||
crc = (crc >> 1) ^ 0xA001;
|
bool c15 = ((wCRCin >> 15) & 1) == 1;
|
||||||
} else {
|
wCRCin = wCRCin << 1;
|
||||||
crc = crc >> 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) {
|
class BluetoothManager(private val context: Context) {
|
||||||
|
|
||||||
// PPTM命令编码器
|
|
||||||
private val pptmEncoder = PPTMCommandEncoder()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BluetoothManager"
|
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_QUERY_DEVICE_INFO = 0x0000 // 查询设备信息
|
||||||
const val FUNC_START_COLLECTION = 0x0001 // 开启采集
|
const val FUNC_START_COLLECTION = 0x0001 // 采集使能开关(带时间戳)
|
||||||
const val FUNC_QUERY_BATTERY = 0x0002 // 查询电量
|
const val FUNC_QUERY_BATTERY = 0x0002 // 电量查询
|
||||||
const val FUNC_SET_SAMPLE_RATE = 0x0003 // 设置采样率
|
const val FUNC_STIM_SWITCH = 0x0003 // 电刺激开关
|
||||||
const val FUNC_SET_GAIN = 0x0004 // 设置增益
|
const val FUNC_POWER_LINE_FILTER = 0x000A // 工频滤波开关
|
||||||
const val FUNC_SET_FILTER = 0x0005 // 设置滤波器
|
const val FUNC_SYNC_TIMESTAMP = 0x0080 // 同步时间戳(64bit毫秒)
|
||||||
const val FUNC_SET_LEAD = 0x0006 // 设置导联
|
const val FUNC_DATA_STREAM = 0x8000 // 数据上传
|
||||||
const val FUNC_SET_ALARM = 0x0007 // 设置报警
|
const val FUNC_ALARM_DATA = 0x8001 // 设备状态上报
|
||||||
const val FUNC_SET_TIME = 0x0008 // 设置时间
|
const val FUNC_STATUS_REPORT = 0x8002 // 电量上报
|
||||||
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 PACKET_TYPE_COMMAND = 0x00 // 命令包
|
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_OFF = 0x00 // 关闭工频滤波
|
||||||
const val POWER_LINE_FILTER_ON = 0x01 // 开启工频滤波
|
const val POWER_LINE_FILTER_ON = 0x01 // 开启工频滤波
|
||||||
|
|
||||||
// 采样率
|
// 已移除:采样率、增益、导联设置常量(按PDF协议未定义)
|
||||||
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导联
|
|
||||||
|
|
||||||
// 响应状态码
|
// 响应状态码
|
||||||
const val RESPONSE_SUCCESS = 0x00 // 成功
|
const val RESPONSE_SUCCESS = 0x00 // 成功
|
||||||
|
|
@ -156,9 +121,7 @@ class BluetoothManager(private val context: Context) {
|
||||||
// 协议状态
|
// 协议状态
|
||||||
private var deviceInfo: DeviceInfo? = null
|
private var deviceInfo: DeviceInfo? = null
|
||||||
private var isCollecting = false
|
private var isCollecting = false
|
||||||
private var currentSampleRate = Protocol.SAMPLE_RATE_500
|
// 已移除:采样率、增益、导联状态变量(按PDF协议未定义)
|
||||||
private var currentGain = Protocol.GAIN_1
|
|
||||||
private var currentLead = Protocol.LEAD_II
|
|
||||||
private var powerLineFilterEnabled = false
|
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 {
|
try {
|
||||||
val packet = buildProtocolPacket(Protocol.FUNC_QUERY_DEVICE_INFO)
|
val data = ByteArray(1)
|
||||||
Log.d(TAG, "发送查询设备信息指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
// 开关及类型控制
|
||||||
|
// 高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)
|
sendCommand(packet)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "构建查询设备信息指令失败: ${e.message}")
|
Log.e(TAG, "构建电刺激开关指令失败: ${e.message}")
|
||||||
callback?.onCommandSent(false, "构建查询设备信息指令失败: ${e.message}")
|
callback?.onCommandSent(false, "构建电刺激开关指令失败: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询电量
|
* 同步时间戳
|
||||||
* 功能码: 0x0002
|
* 功能码: 0x0080
|
||||||
|
* 数据格式: [时间戳(8字节, 小端, 毫秒)]
|
||||||
*/
|
*/
|
||||||
fun queryBattery() {
|
fun syncTimestamp(timestampMs: Long) {
|
||||||
try {
|
try {
|
||||||
val packet = buildProtocolPacket(Protocol.FUNC_QUERY_BATTERY)
|
val data = ByteArray(8)
|
||||||
Log.d(TAG, "发送查询电量指令: ${packet.joinToString(", ") { "0x%02X".format(it) }}")
|
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)
|
sendCommand(packet)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "构建查询电量指令失败: ${e.message}")
|
Log.e(TAG, "构建同步时间戳指令失败: ${e.message}")
|
||||||
callback?.onCommandSent(false, "构建查询电量指令失败: ${e.message}")
|
callback?.onCommandSent(false, "构建同步时间戳指令失败: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,67 +920,21 @@ class BluetoothManager(private val context: Context) {
|
||||||
* 功能码: 0x0003
|
* 功能码: 0x0003
|
||||||
* 数据格式: [采样率(2字节)]
|
* 数据格式: [采样率(2字节)]
|
||||||
*/
|
*/
|
||||||
fun setSampleRate(sampleRate: Int) {
|
// 已移除:设置采样率(按PDF未列出)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置增益
|
* 设置增益
|
||||||
* 功能码: 0x0004
|
* 功能码: 0x0004
|
||||||
* 数据格式: [增益(1字节)]
|
* 数据格式: [增益(1字节)]
|
||||||
*/
|
*/
|
||||||
fun setGain(gain: Int) {
|
// 已移除:设置增益(按PDF未列出)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置导联
|
* 设置导联
|
||||||
* 功能码: 0x0006
|
* 功能码: 0x0006
|
||||||
* 数据格式: [导联(1字节)]
|
* 数据格式: [导联(1字节)]
|
||||||
*/
|
*/
|
||||||
fun setLead(lead: Int) {
|
// 已移除:设置导联(按PDF未列出)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工频滤波开关
|
* 工频滤波开关
|
||||||
|
|
@ -1030,31 +962,13 @@ class BluetoothManager(private val context: Context) {
|
||||||
* 查询状态
|
* 查询状态
|
||||||
* 功能码: 0x000B
|
* 功能码: 0x000B
|
||||||
*/
|
*/
|
||||||
fun queryStatus() {
|
// 已移除:查询状态(按PDF未列出)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备复位
|
* 设备复位
|
||||||
* 功能码: 0x000C
|
* 功能码: 0x000C
|
||||||
*/
|
*/
|
||||||
fun resetDevice() {
|
// 已移除:设备复位(按PDF未列出)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取协议统计信息
|
* 获取协议统计信息
|
||||||
|
|
@ -1066,9 +980,7 @@ class BluetoothManager(private val context: Context) {
|
||||||
"totalPacketsReceived" to totalPacketsReceived,
|
"totalPacketsReceived" to totalPacketsReceived,
|
||||||
"totalBytesReceived" to totalBytesReceived,
|
"totalBytesReceived" to totalBytesReceived,
|
||||||
"isCollecting" to isCollecting,
|
"isCollecting" to isCollecting,
|
||||||
"currentSampleRate" to currentSampleRate,
|
// 已移除:采样率、增益、导联状态(按PDF协议未定义)
|
||||||
"currentGain" to currentGain,
|
|
||||||
"currentLead" to currentLead,
|
|
||||||
"powerLineFilterEnabled" to powerLineFilterEnabled,
|
"powerLineFilterEnabled" to powerLineFilterEnabled,
|
||||||
"deviceInfo" to deviceInfo
|
"deviceInfo" to deviceInfo
|
||||||
)
|
)
|
||||||
|
|
@ -1164,21 +1076,21 @@ class BluetoothManager(private val context: Context) {
|
||||||
* 计算CRC16-CCITT-FALSE校验
|
* 计算CRC16-CCITT-FALSE校验
|
||||||
*/
|
*/
|
||||||
private fun calculateCRC16(data: ByteArray, offset: Int, length: Int): Int {
|
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) {
|
for (i in offset until offset + length) {
|
||||||
crc = crc xor (data[i].toInt() and 0xFF)
|
|
||||||
for (j in 0..7) {
|
for (j in 0..7) {
|
||||||
if ((crc and 0x0001) != 0) {
|
val bit = ((data[i].toInt() shr (7 - j)) and 1) == 1
|
||||||
crc = crc shr 1
|
val c15 = ((wCRCin shr 15) and 1) == 1
|
||||||
crc = crc xor 0x8408
|
wCRCin = wCRCin shl 1
|
||||||
} else {
|
if (c15 xor bit) {
|
||||||
crc = crc shr 1
|
wCRCin = wCRCin xor wCPoly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return crc
|
return wCRCin and 0xFFFF
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1691,80 +1603,4 @@ class BluetoothManager(private val context: Context) {
|
||||||
isCleaningUp = true
|
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.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
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)
|
// processStreamingData(packets)
|
||||||
} else {
|
} else {
|
||||||
Log.w("DataManager", "没有解析出有效数据包,尝试生成测试数据")
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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方法
|
// Getter方法
|
||||||
fun getPacketBufferSize(): Int = packetBuffer.size
|
fun getPacketBufferSize(): Int = packetBuffer.size
|
||||||
fun getProcessedPacketsSize(): Int = processedPackets.size
|
|
||||||
fun getCalculatedMetricsSize(): Int = calculatedMetrics.size
|
|
||||||
fun getRawStreamSize(): Int = rawStream.size()
|
fun getRawStreamSize(): Int = rawStream.size()
|
||||||
fun getTotalPacketsParsed(): Long = totalPacketsParsed
|
fun getTotalPacketsParsed(): Long = totalPacketsParsed
|
||||||
fun getPacketBuffer(): List<SensorData> = packetBuffer
|
fun getPacketBuffer(): List<SensorData> = packetBuffer
|
||||||
fun getProcessedPackets(): List<SensorData> = processedPackets
|
fun getProcessedPackets(): List<SensorData> = processedPackets
|
||||||
fun getCalculatedMetrics(): List<Map<String, Float>> = calculatedMetrics
|
fun getCalculatedMetrics(): List<Map<String, Float>> = calculatedMetrics
|
||||||
fun getLatestMetrics(): Map<String, Float> = latestMetrics
|
|
||||||
fun getChannelBuffersStatus(): Map<Int, Int> = channelBuffers.mapValues { it.value.size }
|
|
||||||
|
|
||||||
// 流式处理相关getter方法
|
// 流式处理相关getter方法
|
||||||
fun getProcessedChannelBuffersStatus(): Map<Int, Int> = processedChannelBuffers.mapValues { it.value.size }
|
|
||||||
fun getProcessedChannelData(channelIndex: Int): List<Float> = processedChannelBuffers[channelIndex] ?: emptyList()
|
fun getProcessedChannelData(channelIndex: Int): List<Float> = processedChannelBuffers[channelIndex] ?: emptyList()
|
||||||
fun getTotalProcessedSamples(): Long = totalProcessedSamples
|
fun getTotalProcessedSamples(): Long = totalProcessedSamples
|
||||||
fun getCurrentDataType(): type.SensorData.DataType? = currentDataType
|
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() {
|
private fun showCommandDialog() {
|
||||||
val commands = arrayOf(
|
val commands = arrayOf(
|
||||||
"轻迅协议指令",
|
"轻迅协议指令"
|
||||||
"PPTM协议指令"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||||
|
|
@ -981,7 +980,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
.setItems(commands) { _, which ->
|
.setItems(commands) { _, which ->
|
||||||
when (which) {
|
when (which) {
|
||||||
0 -> showQingXunProtocolDialog()
|
0 -> showQingXunProtocolDialog()
|
||||||
1 -> showPptmProtocolDialog()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
|
|
@ -995,8 +993,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
val commands = arrayOf(
|
val commands = arrayOf(
|
||||||
"开启采集",
|
"开启采集",
|
||||||
"停止采集",
|
"停止采集",
|
||||||
"查询设备信息",
|
"电刺激开关",
|
||||||
"查询电量",
|
|
||||||
"工频滤波开关"
|
"工频滤波开关"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1006,87 +1003,8 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
when (which) {
|
when (which) {
|
||||||
0 -> sendQingXunStartCollection()
|
0 -> sendQingXunStartCollection()
|
||||||
1 -> sendQingXunStopCollection()
|
1 -> sendQingXunStopCollection()
|
||||||
2 -> bluetoothManager.queryDeviceInfo()
|
2 -> showStimSwitchDialog()
|
||||||
3 -> bluetoothManager.queryBattery()
|
3 -> showPowerLineFilterDialog()
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
|
|
@ -1102,9 +1020,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
*/
|
*/
|
||||||
private fun sendQingXunStartCollection() {
|
private fun sendQingXunStartCollection() {
|
||||||
val options = arrayOf(
|
val options = arrayOf(
|
||||||
"立即开启",
|
"立即开启"
|
||||||
"延迟开启(5秒后)",
|
|
||||||
"指定时间戳开启"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||||
|
|
@ -1115,17 +1031,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
bluetoothManager.startCollection(0L) // 立即开启
|
bluetoothManager.startCollection(0L) // 立即开启
|
||||||
updateStatus("发送轻迅协议开启采集指令(立即),准备接收数据...")
|
updateStatus("发送轻迅协议开启采集指令(立即),准备接收数据...")
|
||||||
}
|
}
|
||||||
1 -> {
|
|
||||||
val timestamp = System.currentTimeMillis() + 5000 // 5秒后
|
|
||||||
bluetoothManager.startCollection(timestamp)
|
|
||||||
updateStatus("发送轻迅协议开启采集指令(5秒后),准备接收数据...")
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
showTimestampInputDialog { timestamp ->
|
|
||||||
bluetoothManager.startCollection(timestamp)
|
|
||||||
updateStatus("发送轻迅协议开启采集指令(指定时间戳: $timestamp),准备接收数据...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
|
|
@ -1137,9 +1042,7 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
*/
|
*/
|
||||||
private fun sendQingXunStopCollection() {
|
private fun sendQingXunStopCollection() {
|
||||||
val options = arrayOf(
|
val options = arrayOf(
|
||||||
"立即停止",
|
"立即停止"
|
||||||
"延迟停止(5秒后)",
|
|
||||||
"指定时间戳停止"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||||
|
|
@ -1150,17 +1053,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
bluetoothManager.stopCollection(0L) // 立即停止
|
bluetoothManager.stopCollection(0L) // 立即停止
|
||||||
updateStatus("发送轻迅协议停止采集指令(立即)")
|
updateStatus("发送轻迅协议停止采集指令(立即)")
|
||||||
}
|
}
|
||||||
1 -> {
|
|
||||||
val timestamp = System.currentTimeMillis() + 5000 // 5秒后
|
|
||||||
bluetoothManager.stopCollection(timestamp)
|
|
||||||
updateStatus("发送轻迅协议停止采集指令(5秒后)")
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
showTimestampInputDialog { timestamp ->
|
|
||||||
bluetoothManager.stopCollection(timestamp)
|
|
||||||
updateStatus("发送轻迅协议停止采集指令(指定时间戳: $timestamp)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
|
|
@ -1195,71 +1087,30 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示时间戳输入对话框
|
* 显示电刺激开关对话框
|
||||||
*/
|
*/
|
||||||
private fun showTimestampInputDialog(onConfirm: (Long) -> Unit) {
|
private fun showStimSwitchDialog() {
|
||||||
val input = android.widget.EditText(this)
|
val stimOptions = arrayOf(
|
||||||
input.hint = "输入时间戳(毫秒,0表示立即执行)"
|
"开启刺激A",
|
||||||
input.setText("0")
|
"开启刺激B",
|
||||||
|
"关闭电刺激"
|
||||||
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(
|
|
||||||
"查询设备信息",
|
|
||||||
"开启采集",
|
|
||||||
"停止采集",
|
|
||||||
"查询电量",
|
|
||||||
"设置采样率",
|
|
||||||
"设置增益",
|
|
||||||
"设置导联",
|
|
||||||
"工频滤波开关",
|
|
||||||
"查询状态",
|
|
||||||
"设备复位",
|
|
||||||
"查看协议统计"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||||
.setTitle("轻迅协议V1.0.1命令")
|
.setTitle("电刺激开关")
|
||||||
.setItems(options) { _, which ->
|
.setItems(stimOptions) { _, which ->
|
||||||
when (which) {
|
when (which) {
|
||||||
0 -> {
|
0 -> {
|
||||||
bluetoothManager.queryDeviceInfo()
|
bluetoothManager.stimSwitch(0, true)
|
||||||
updateStatus("发送查询设备信息指令")
|
updateStatus("发送电刺激开关指令: 刺激A 开启")
|
||||||
}
|
}
|
||||||
1 -> sendQingXunStartCollection()
|
1 -> {
|
||||||
2 -> sendQingXunStopCollection()
|
bluetoothManager.stimSwitch(1, true)
|
||||||
3 -> {
|
updateStatus("发送电刺激开关指令: 刺激B 开启")
|
||||||
bluetoothManager.queryBattery()
|
|
||||||
updateStatus("发送查询电量指令")
|
|
||||||
}
|
}
|
||||||
4 -> showSampleRateDialog()
|
2 -> {
|
||||||
5 -> showGainDialog()
|
bluetoothManager.stimSwitch(0, false)
|
||||||
6 -> showLeadDialog()
|
updateStatus("发送电刺激开关指令: 关闭电刺激")
|
||||||
7 -> showPowerLineFilterDialog()
|
|
||||||
8 -> {
|
|
||||||
bluetoothManager.queryStatus()
|
|
||||||
updateStatus("发送查询状态指令")
|
|
||||||
}
|
|
||||||
9 -> {
|
|
||||||
bluetoothManager.resetDevice()
|
|
||||||
updateStatus("发送设备复位指令")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1268,150 +1119,15 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示采样率设置对话框
|
* 显示调试状态对话框
|
||||||
*/
|
*/
|
||||||
private fun showSampleRateDialog() {
|
private fun showDebugStatus() {
|
||||||
val options = arrayOf(
|
val status = bluetoothManager.debugConnectionStatus()
|
||||||
"125 Hz",
|
|
||||||
"250 Hz",
|
|
||||||
"500 Hz",
|
|
||||||
"1000 Hz",
|
|
||||||
"2000 Hz",
|
|
||||||
"自定义"
|
|
||||||
)
|
|
||||||
|
|
||||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||||
.setTitle("设置采样率")
|
.setTitle("协议统计信息")
|
||||||
.setItems(options) { _, which ->
|
.setMessage(status)
|
||||||
when (which) {
|
.setPositiveButton("确定", null)
|
||||||
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)
|
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1426,16 +1142,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
updateStatus("已清空所有数据")
|
updateStatus("已清空所有数据")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 测试图表显示功能
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示调试状态对话框
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun startDataMonitoring() {
|
private fun startDataMonitoring() {
|
||||||
if (!isDataMonitoringEnabled) {
|
if (!isDataMonitoringEnabled) {
|
||||||
isDataMonitoringEnabled = true
|
isDataMonitoringEnabled = true
|
||||||
|
|
@ -1572,18 +1278,6 @@ class MainActivity : AppCompatActivity(), NativeMethodCallback, DataManager.Real
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出设备信息
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出节律视图数据
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出波形视图数据
|
|
||||||
*/
|
|
||||||
private fun exportWaveformData(data: List<Float>, stats: Map<String, Any>) {
|
private fun exportWaveformData(data: List<Float>, stats: Map<String, Any>) {
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
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 {
|
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 val uiUpdatePending = AtomicBoolean(false)
|
||||||
private var lastUpdateTime = 0L
|
private var lastUpdateTime = 0L
|
||||||
|
|
||||||
|
|
@ -21,7 +92,7 @@ class UiManager {
|
||||||
updateCallback: () -> Unit
|
updateCallback: () -> Unit
|
||||||
) {
|
) {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
if (currentTime - lastUpdateTime >= Constants.UPDATE_INTERVAL &&
|
if (currentTime - lastUpdateTime >= UPDATE_INTERVAL &&
|
||||||
uiUpdatePending.compareAndSet(false, true)) {
|
uiUpdatePending.compareAndSet(false, true)) {
|
||||||
lastUpdateTime = currentTime
|
lastUpdateTime = currentTime
|
||||||
updateCallback()
|
updateCallback()
|
||||||
|
|
@ -45,7 +116,7 @@ class UiManager {
|
||||||
if (packetBuffer.isNotEmpty()) {
|
if (packetBuffer.isNotEmpty()) {
|
||||||
// 获取设备类型信息
|
// 获取设备类型信息
|
||||||
val deviceTypes = packetBuffer.mapNotNull { it.dataType }.distinct()
|
val deviceTypes = packetBuffer.mapNotNull { it.dataType }.distinct()
|
||||||
val deviceInfo = deviceTypes.joinToString(", ") { DeviceTypeHelper.getDeviceName(it) }
|
val deviceInfo = deviceTypes.joinToString(", ") { getDeviceName(it) }
|
||||||
append("设备类型: $deviceInfo\n")
|
append("设备类型: $deviceInfo\n")
|
||||||
} else {
|
} else {
|
||||||
append("设备类型: 无\n")
|
append("设备类型: 无\n")
|
||||||
|
|
@ -82,22 +153,22 @@ class UiManager {
|
||||||
val metricsInfo = buildMetricsInfo(calculatedMetrics)
|
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 has12Lead = recentPackets.any { it.dataType == SensorData.DataType.ECG_12LEAD }
|
||||||
|
|
||||||
val channelDetails = if (has12Lead) {
|
val channelDetails = if (has12Lead) {
|
||||||
DeviceTypeHelper.buildChannelDetails(
|
buildChannelDetails(
|
||||||
recentPackets,
|
recentPackets,
|
||||||
maxPackets = Constants.MAX_DETAIL_PACKETS,
|
maxPackets = MAX_DETAIL_PACKETS,
|
||||||
maxChannels = Constants.MAX_12LEAD_CHANNELS,
|
maxChannels = MAX_12LEAD_CHANNELS,
|
||||||
maxSamples = Constants.MAX_DISPLAY_SAMPLES
|
maxSamples = MAX_DISPLAY_SAMPLES
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DeviceTypeHelper.buildChannelDetails(
|
buildChannelDetails(
|
||||||
recentPackets,
|
recentPackets,
|
||||||
maxPackets = Constants.MAX_DETAIL_PACKETS,
|
maxPackets = MAX_DETAIL_PACKETS,
|
||||||
maxChannels = Constants.MAX_DISPLAY_CHANNELS,
|
maxChannels = MAX_DISPLAY_CHANNELS,
|
||||||
maxSamples = Constants.MAX_DISPLAY_SAMPLES
|
maxSamples = MAX_DISPLAY_SAMPLES
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,7 +204,7 @@ class UiManager {
|
||||||
val latestPacket = processedPackets.lastOrNull()
|
val latestPacket = processedPackets.lastOrNull()
|
||||||
if (latestPacket != null) {
|
if (latestPacket != null) {
|
||||||
append("\n最新处理数据包:\n")
|
append("\n最新处理数据包:\n")
|
||||||
append("- 数据类型: ${DeviceTypeHelper.getDeviceName(latestPacket.getDataType())}\n")
|
append("- 数据类型: ${getDeviceName(latestPacket.getDataType())}\n")
|
||||||
append("- 时间戳: ${latestPacket.getTimestamp()}\n")
|
append("- 时间戳: ${latestPacket.getTimestamp()}\n")
|
||||||
append("- 包序号: ${latestPacket.getPacketSn()}\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