No Multible
This commit is contained in:
parent
f87f090542
commit
42250dff0e
|
|
@ -0,0 +1 @@
|
|||
Cmake_project_test
|
||||
|
|
@ -2,16 +2,8 @@
|
|||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<SelectionState runConfigName="Unnamed">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-09-02T08:19:09.762084200Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\29096\.android\avd\Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
# 蓝牙连接问题解决方案
|
||||
|
||||
## 🔍 问题分析
|
||||
|
||||
你遇到的问题是:应用一直在扫描蓝牙设备,但没有找到设备可以连接。
|
||||
|
||||
## 🎯 解决方案
|
||||
|
||||
### 1. 页面布局优化 ✅
|
||||
|
||||
**已完成的改进:**
|
||||
- **添加了图表标题**:显示"ECG实时监测"
|
||||
- **优化了布局比例**:图表区域占70%,文本区域占30%
|
||||
- **添加了默认显示内容**:在没有数据时显示提示信息和示例波形
|
||||
|
||||
**现在的显示效果:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [连接蓝牙] [启动程序] │
|
||||
│ [停止程序] [陷波滤波] │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ECG实时监测 │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 等待数据... │ │
|
||||
│ │ 请先连接蓝牙设备 │ │
|
||||
│ │ 然后点击'启动程序' │ │
|
||||
│ │ [示例波形图] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 等待数据... │ │
|
||||
│ │ 请先连接蓝牙设备 │ │
|
||||
│ │ 然后点击'启动程序' │ │
|
||||
│ │ [示例波形图] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 状态信息区域 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 蓝牙扫描优化 ✅
|
||||
|
||||
**已完成的改进:**
|
||||
- **移除设备名称过滤**:现在会扫描并显示所有发现的蓝牙设备
|
||||
- **支持更多设备类型**:包括手机、耳机、手表等所有蓝牙设备
|
||||
- **双模式扫描**:优先使用BLE,回退到传统蓝牙
|
||||
|
||||
**扫描逻辑:**
|
||||
```kotlin
|
||||
// 之前:只显示包含"ECG"、"心电"关键词的设备
|
||||
if (device.name?.contains("ECG", ignoreCase = true) == true) {
|
||||
addDiscoveredDevice(device)
|
||||
}
|
||||
|
||||
// 现在:显示所有发现的设备
|
||||
addDiscoveredDevice(device)
|
||||
```
|
||||
|
||||
## 🚀 使用步骤
|
||||
|
||||
### 第一步:测试蓝牙扫描
|
||||
1. **确保蓝牙已开启**
|
||||
- 进入系统设置 → 蓝牙 → 开启蓝牙
|
||||
|
||||
2. **点击"连接蓝牙"按钮**
|
||||
- 系统会申请权限(如果还没有)
|
||||
- 开始扫描附近设备
|
||||
|
||||
3. **观察扫描结果**
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: iPhone (00:11:22:33:44:55)
|
||||
发现设备: AirPods (AA:BB:CC:DD:EE:FF)
|
||||
发现设备: 小米手环 (11:22:33:44:55:66)
|
||||
扫描完成,找到 3 个设备
|
||||
```
|
||||
|
||||
### 第二步:选择测试设备
|
||||
1. **扫描完成后会弹出设备选择对话框**
|
||||
2. **选择任意一个设备进行测试**(比如你的手机或耳机)
|
||||
3. **点击设备名称进行连接**
|
||||
|
||||
### 第三步:测试连接功能
|
||||
1. **连接过程观察**
|
||||
```
|
||||
正在连接设备: iPhone
|
||||
设备已连接
|
||||
服务发现成功
|
||||
数据通道已建立
|
||||
```
|
||||
|
||||
2. **连接状态验证**
|
||||
- 按钮文字变为"断开蓝牙"
|
||||
- 按钮颜色变为红色
|
||||
|
||||
## 🔧 测试建议
|
||||
|
||||
### 1. 使用模拟数据测试
|
||||
如果没有真实的心电设备,可以:
|
||||
|
||||
**方法一:使用其他蓝牙设备**
|
||||
- 连接你的手机、耳机、手表等
|
||||
- 验证连接功能是否正常
|
||||
- 观察数据接收情况
|
||||
|
||||
**方法二:使用蓝牙调试工具**
|
||||
- 下载蓝牙调试应用(如nRF Connect)
|
||||
- 模拟发送数据包
|
||||
- 测试数据接收功能
|
||||
|
||||
### 2. 检查设备兼容性
|
||||
```bash
|
||||
# 查看蓝牙相关日志
|
||||
adb logcat | grep BluetoothManager
|
||||
|
||||
# 查看权限状态
|
||||
adb shell dumpsys package com.example.cmake_project_test | grep permission
|
||||
```
|
||||
|
||||
### 3. 常见设备类型
|
||||
- **手机**:iPhone, Samsung, Huawei, Xiaomi
|
||||
- **耳机**:AirPods, Sony, Bose, Sennheiser
|
||||
- **手表**:Apple Watch, Samsung Galaxy Watch
|
||||
- **手环**:小米手环, 华为手环, Fitbit
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 权限要求
|
||||
确保已授予以下权限:
|
||||
- `BLUETOOTH_SCAN`
|
||||
- `BLUETOOTH_CONNECT`
|
||||
- `ACCESS_FINE_LOCATION`
|
||||
- `ACCESS_COARSE_LOCATION`
|
||||
|
||||
### 2. 设备距离
|
||||
- 确保设备在蓝牙有效范围内(通常10米内)
|
||||
- 确保设备处于可发现状态
|
||||
|
||||
### 3. 连接限制
|
||||
- 某些设备可能不支持GATT连接
|
||||
- 某些设备可能需要配对密码
|
||||
- 某些设备可能被其他应用占用
|
||||
|
||||
## 🎯 下一步测试
|
||||
|
||||
### 1. 功能验证
|
||||
- [ ] 蓝牙扫描正常工作
|
||||
- [ ] 设备选择对话框显示
|
||||
- [ ] 设备连接成功
|
||||
- [ ] 连接状态正确显示
|
||||
- [ ] 断开连接功能正常
|
||||
|
||||
### 2. 界面验证
|
||||
- [ ] 图表区域显示默认内容
|
||||
- [ ] 提示信息清晰可见
|
||||
- [ ] 示例波形正常显示
|
||||
- [ ] 布局比例合理
|
||||
|
||||
### 3. 性能验证
|
||||
- [ ] 扫描响应及时
|
||||
- [ ] 连接过程流畅
|
||||
- [ ] 界面无卡顿
|
||||
- [ ] 内存使用合理
|
||||
|
||||
## 🔮 后续优化
|
||||
|
||||
### 1. 设备过滤选项
|
||||
可以添加一个开关,让用户选择是否过滤设备:
|
||||
```kotlin
|
||||
// 添加过滤开关
|
||||
private var enableDeviceFilter = false
|
||||
|
||||
// 条件过滤
|
||||
if (!enableDeviceFilter || device.name?.contains("ECG", ignoreCase = true) == true) {
|
||||
addDiscoveredDevice(device)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 设备类型识别
|
||||
可以自动识别设备类型并分类显示:
|
||||
```kotlin
|
||||
// 设备类型识别
|
||||
enum class DeviceType {
|
||||
ECG_DEVICE, // 心电设备
|
||||
PHONE, // 手机
|
||||
HEADPHONES, // 耳机
|
||||
WATCH, // 手表
|
||||
OTHER // 其他
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 连接历史
|
||||
保存最近连接的设备列表:
|
||||
```kotlin
|
||||
// 保存连接历史
|
||||
private val connectionHistory = mutableListOf<BluetoothDevice>()
|
||||
```
|
||||
|
||||
现在你可以测试蓝牙连接功能了!建议先连接一个普通的蓝牙设备(如手机或耳机)来验证连接功能是否正常工作。🎉
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
# 蓝牙数据流处理指南
|
||||
|
||||
## 🎯 数据流处理架构
|
||||
|
||||
### 数据流向
|
||||
```
|
||||
蓝牙设备 → BluetoothManager → MainActivity → DataManager → ECG图表显示
|
||||
```
|
||||
|
||||
### 处理流程
|
||||
1. **蓝牙数据接收**: `BluetoothManager.onCharacteristicChanged`
|
||||
2. **数据传递**: `MainActivity.onDataReceived`
|
||||
3. **数据解析**: `DataManager.onBleNotify`
|
||||
4. **信号处理**: `DataManager.processStreamingData`
|
||||
5. **图表更新**: `MainActivity.onProcessedDataAvailable`
|
||||
|
||||
## 🔧 已完成的修改
|
||||
|
||||
### 1. BluetoothManager ✅
|
||||
- **Nordic UART Service (NUS)** 协议支持
|
||||
- **自动UUID匹配**: `6e400001-b5a3-f393-e0a9-68716563686f`
|
||||
- **数据接收**: `onCharacteristicChanged` 回调
|
||||
- **状态管理**: 连接状态、错误处理
|
||||
|
||||
### 2. MainActivity ✅
|
||||
- **蓝牙回调实现**: `BluetoothManager.BluetoothCallback`
|
||||
- **数据接收处理**: `onDataReceived` 方法
|
||||
- **实时数据回调**: `DataManager.RealTimeDataCallback`
|
||||
- **图表更新**: `onProcessedDataAvailable` 方法
|
||||
|
||||
### 3. DataManager ✅
|
||||
- **蓝牙数据处理**: `onBleNotify` 方法
|
||||
- **流式数据处理**: `processStreamingData` 方法
|
||||
- **信号处理**: 高通滤波 → 低通滤波 → 陷波滤波
|
||||
- **实时回调**: `onProcessedDataAvailable` 回调
|
||||
|
||||
### 4. ECG图表 ✅
|
||||
- **实时更新**: `ECGRhythmView` 和 `ECGWaveformView`
|
||||
- **数据缓冲**: 性能优化的数据缓冲机制
|
||||
- **双视图显示**: 10秒节奏视图 + 2.5秒波形视图
|
||||
|
||||
## 📱 测试步骤
|
||||
|
||||
### 第一步:连接蓝牙设备
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **等待扫描完成**
|
||||
3. **选择你的ECG设备**
|
||||
4. **等待连接成功**
|
||||
|
||||
### 第二步:验证数据接收
|
||||
1. **观察状态信息**:
|
||||
```
|
||||
蓝牙状态: 设备已连接
|
||||
蓝牙状态: 服务发现成功
|
||||
蓝牙状态: 发现服务: 6e400001-b5a3-f393-e0a9-68716563686f
|
||||
蓝牙状态: 发现特征: 6e400002-b5a3-f393-e0a9-68716563686f
|
||||
蓝牙状态: 数据通道已建立,开始接收数据
|
||||
```
|
||||
|
||||
2. **检查数据接收**:
|
||||
```
|
||||
接收到蓝牙数据: X 字节
|
||||
解析出 X 个数据包
|
||||
```
|
||||
|
||||
### 第三步:验证图表显示
|
||||
1. **ECG图表变为可见**
|
||||
2. **实时波形开始显示**
|
||||
3. **双视图同步更新**:
|
||||
- **节奏视图**: 10秒显示窗口
|
||||
- **波形视图**: 2.5秒显示窗口
|
||||
|
||||
### 第四步:验证信号处理
|
||||
1. **观察处理进度**:
|
||||
```
|
||||
=== 实时流式处理 ===
|
||||
处理进度: XX%
|
||||
总样本数: XXX
|
||||
已处理样本: XXX
|
||||
实时曲线图已更新 ✓
|
||||
```
|
||||
|
||||
2. **检查滤波效果**:
|
||||
- 信号应该更加平滑
|
||||
- 噪声应该被有效抑制
|
||||
- 波形应该清晰可见
|
||||
|
||||
## 🔍 关键代码位置
|
||||
|
||||
### 1. 蓝牙数据接收
|
||||
```kotlin
|
||||
// BluetoothManager.kt - 第468行
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
val data = characteristic.value
|
||||
callback?.onDataReceived(data)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 数据传递处理
|
||||
```kotlin
|
||||
// MainActivity.kt - 第656行
|
||||
override fun onDataReceived(data: ByteArray) {
|
||||
Thread {
|
||||
dataManager.onBleNotify(data)
|
||||
runOnUiThread {
|
||||
binding.ecgChartContainer.visibility = View.VISIBLE
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据解析处理
|
||||
```kotlin
|
||||
// DataManager.kt - 第73行
|
||||
fun onBleNotify(chunk: ByteArray) {
|
||||
ensureParser()
|
||||
nativeCallback.streamParserAppend(parserHandle, chunk)
|
||||
val packets = nativeCallback.streamParserDrainPackets(parserHandle)
|
||||
processStreamingData(packets)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 图表更新
|
||||
```kotlin
|
||||
// MainActivity.kt - 第558行
|
||||
override fun onProcessedDataAvailable(channelIndex: Int, processedData: List<Float>) {
|
||||
binding.ecgRhythmView.updateData(processedData)
|
||||
binding.ecgWaveformView.updateData(processedData)
|
||||
}
|
||||
```
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 连接成功时:
|
||||
- ✅ 蓝牙设备连接成功
|
||||
- ✅ 数据通道建立
|
||||
- ✅ 开始接收ECG数据
|
||||
|
||||
### 数据处理时:
|
||||
- ✅ 实时数据解析
|
||||
- ✅ 信号滤波处理
|
||||
- ✅ 通道映射完成
|
||||
|
||||
### 图表显示时:
|
||||
- ✅ ECG图表可见
|
||||
- ✅ 实时波形显示
|
||||
- ✅ 双视图同步更新
|
||||
- ✅ 平滑的动画效果
|
||||
|
||||
### 性能优化:
|
||||
- ✅ 后台数据处理
|
||||
- ✅ UI线程不阻塞
|
||||
- ✅ 数据缓冲机制
|
||||
- ✅ 实时回调更新
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据格式
|
||||
- **Nordic UART Service**: 原始字节流
|
||||
- **数据解析**: 自动协议解析
|
||||
- **通道映射**: 8通道 → 12通道
|
||||
|
||||
### 2. 性能考虑
|
||||
- **后台处理**: 避免UI阻塞
|
||||
- **数据缓冲**: 优化内存使用
|
||||
- **更新频率**: 平衡实时性和性能
|
||||
|
||||
### 3. 错误处理
|
||||
- **连接失败**: 自动重试机制
|
||||
- **数据异常**: 错误日志记录
|
||||
- **UI异常**: 异常捕获处理
|
||||
|
||||
现在你的应用已经完全支持蓝牙数据流处理,可以实时接收、处理和显示ECG数据了!🚀
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
# 蓝牙功能完整特性说明
|
||||
|
||||
## 🚀 功能概述
|
||||
|
||||
本应用现已具备完整的蓝牙连接功能,支持真实的心电设备连接和数据接收。
|
||||
|
||||
## 🔧 核心特性
|
||||
|
||||
### 1. 双模式蓝牙扫描
|
||||
- **BLE扫描**:优先使用低功耗蓝牙扫描,更快速、更节能
|
||||
- **传统蓝牙扫描**:兼容不支持BLE的设备
|
||||
- **自动切换**:根据设备能力自动选择扫描模式
|
||||
|
||||
### 2. 智能设备过滤
|
||||
- **名称过滤**:自动识别包含"ECG"、"心电"等关键词的设备
|
||||
- **设备分类**:区分BLE和传统蓝牙设备
|
||||
- **重复检测**:避免重复添加相同设备
|
||||
|
||||
### 3. 实时状态管理
|
||||
- **连接状态**:实时显示连接、断开、扫描等状态
|
||||
- **权限检查**:动态检查蓝牙和位置权限
|
||||
- **错误处理**:友好的错误提示和状态恢复
|
||||
|
||||
### 4. 数据流处理
|
||||
- **实时接收**:自动接收蓝牙设备发送的数据
|
||||
- **数据解析**:将接收的数据传递给现有的数据处理管道
|
||||
- **实时显示**:在ECG曲线图上实时显示接收的数据
|
||||
|
||||
## 📱 用户界面
|
||||
|
||||
### 按钮布局优化
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [连接蓝牙] [启动程序] │
|
||||
│ [停止程序] [陷波滤波] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 状态指示
|
||||
- **连接蓝牙**:绿色(未连接)→ 橙色(扫描中)→ 红色(已连接)
|
||||
- **启动程序**:启用/禁用状态管理
|
||||
- **停止程序**:仅在程序运行时启用
|
||||
- **陷波滤波**:仅在程序运行时启用
|
||||
|
||||
## 🔍 技术实现
|
||||
|
||||
### 扫描机制
|
||||
```kotlin
|
||||
// BLE扫描设置
|
||||
val scanSettings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
// 设备过滤
|
||||
if (device.name?.contains("ECG", ignoreCase = true) == true ||
|
||||
device.name?.contains("心电", ignoreCase = true) == true) {
|
||||
addDiscoveredDevice(device)
|
||||
}
|
||||
```
|
||||
|
||||
### 权限管理
|
||||
```kotlin
|
||||
private val REQUIRED_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
```
|
||||
|
||||
### 数据流处理
|
||||
```kotlin
|
||||
override fun onDataReceived(data: ByteArray) {
|
||||
// 将蓝牙数据传递给DataManager
|
||||
dataManager.onBleNotify(data)
|
||||
updateStatus("接收到蓝牙数据: ${data.size} 字节")
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 使用流程
|
||||
|
||||
### 1. 权限申请
|
||||
1. 首次点击"连接蓝牙"按钮
|
||||
2. 系统弹出权限申请对话框
|
||||
3. 选择"允许"授予所有权限
|
||||
|
||||
### 2. 设备扫描
|
||||
1. 权限授予后自动开始扫描
|
||||
2. 扫描持续10秒
|
||||
3. 自动过滤心电相关设备
|
||||
|
||||
### 3. 设备选择
|
||||
1. 扫描完成后显示设备列表
|
||||
2. 选择目标心电设备
|
||||
3. 等待连接建立
|
||||
|
||||
### 4. 数据采集
|
||||
1. 连接成功后点击"启动程序"
|
||||
2. 观察ECG曲线图显示实时数据
|
||||
3. 查看状态信息确认数据接收
|
||||
|
||||
## 🔧 配置参数
|
||||
|
||||
### 蓝牙参数
|
||||
```kotlin
|
||||
// 服务UUID (根据设备协议调整)
|
||||
private val SERVICE_UUID = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// 特征UUID (根据设备协议调整)
|
||||
private val CHARACTERISTIC_UUID = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// 设备名称前缀
|
||||
private const val DEVICE_NAME_PREFIX = "ECG"
|
||||
```
|
||||
|
||||
### 扫描参数
|
||||
- **扫描时间**:10秒
|
||||
- **扫描模式**:低延迟模式
|
||||
- **设备过滤**:包含"ECG"、"心电"关键词
|
||||
|
||||
## 📊 状态监控
|
||||
|
||||
### 连接状态
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: ECG_Device_001 (00:11:22:33:44:55)
|
||||
蓝牙设备已连接: ECG_Device_001
|
||||
接收到蓝牙数据: 128 字节
|
||||
```
|
||||
|
||||
### 权限状态
|
||||
```
|
||||
权限状态:
|
||||
BLUETOOTH_SCAN: ✓
|
||||
BLUETOOTH_CONNECT: ✓
|
||||
ACCESS_FINE_LOCATION: ✓
|
||||
ACCESS_COARSE_LOCATION: ✓
|
||||
```
|
||||
|
||||
## 🔍 调试功能
|
||||
|
||||
### 日志输出
|
||||
- **扫描过程**:记录设备发现和扫描状态
|
||||
- **连接过程**:记录连接建立和断开
|
||||
- **数据接收**:记录数据包大小和频率
|
||||
- **错误处理**:记录详细错误信息
|
||||
|
||||
### 调试命令
|
||||
```bash
|
||||
# 查看蓝牙相关日志
|
||||
adb logcat | grep BluetoothManager
|
||||
|
||||
# 查看权限相关日志
|
||||
adb logcat | grep MainActivity
|
||||
```
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### 1. 扫描无结果
|
||||
**可能原因**:
|
||||
- 设备不在范围内
|
||||
- 设备未开启或未处于可发现状态
|
||||
- 权限未正确授予
|
||||
|
||||
**解决方法**:
|
||||
- 确认设备距离和状态
|
||||
- 检查权限设置
|
||||
- 重启设备和应用
|
||||
|
||||
### 2. 连接失败
|
||||
**可能原因**:
|
||||
- 设备被其他应用占用
|
||||
- 设备电池电量不足
|
||||
- 服务UUID不匹配
|
||||
|
||||
**解决方法**:
|
||||
- 关闭其他蓝牙应用
|
||||
- 检查设备电量
|
||||
- 确认设备协议
|
||||
|
||||
### 3. 数据接收异常
|
||||
**可能原因**:
|
||||
- 数据格式不匹配
|
||||
- 连接不稳定
|
||||
- 设备发送频率过高
|
||||
|
||||
**解决方法**:
|
||||
- 检查数据解析逻辑
|
||||
- 优化连接稳定性
|
||||
- 调整数据处理频率
|
||||
|
||||
## 🔮 未来改进
|
||||
|
||||
### 1. 功能增强
|
||||
- **自动重连**:连接断开时自动尝试重连
|
||||
- **多设备支持**:同时连接多个设备
|
||||
- **数据缓存**:本地缓存数据防止丢失
|
||||
|
||||
### 2. 用户体验
|
||||
- **设备管理**:保存常用设备列表
|
||||
- **连接优化**:优化连接速度和稳定性
|
||||
- **界面美化**:更直观的状态显示
|
||||
|
||||
### 3. 技术优化
|
||||
- **协议适配**:支持更多设备协议
|
||||
- **性能优化**:减少电池消耗
|
||||
- **兼容性**:支持更多Android版本
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **位置权限必需**:蓝牙扫描需要位置权限
|
||||
2. **设备兼容性**:确保设备支持BLE或传统蓝牙
|
||||
3. **数据格式**:确保设备发送的数据格式与解析器兼容
|
||||
4. **连接稳定性**:保持设备在有效范围内
|
||||
5. **电池管理**:注意设备电池电量,避免连接中断
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在你的应用已经具备了完整的蓝牙连接功能:
|
||||
|
||||
✅ **双模式扫描**:支持BLE和传统蓝牙
|
||||
✅ **智能过滤**:自动识别心电设备
|
||||
✅ **权限管理**:完整的权限申请和检查
|
||||
✅ **实时数据**:实时接收和显示数据
|
||||
✅ **状态监控**:详细的状态和错误提示
|
||||
✅ **界面优化**:合理的按钮布局和状态指示
|
||||
|
||||
现在可以连接真实的心电设备并开始数据采集了!🎉
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# 蓝牙连接功能使用说明
|
||||
|
||||
## 📱 功能概述
|
||||
|
||||
本应用已集成蓝牙连接功能,支持连接十二导联心电设备并实时接收数据。
|
||||
|
||||
## 🔧 功能特性
|
||||
|
||||
### 1. 蓝牙设备管理
|
||||
- **自动扫描**:点击"连接蓝牙"按钮自动扫描附近设备
|
||||
- **设备选择**:扫描完成后显示设备列表供用户选择
|
||||
- **状态显示**:实时显示连接状态和数据接收情况
|
||||
|
||||
### 2. 数据流处理
|
||||
- **实时接收**:自动接收蓝牙设备发送的数据
|
||||
- **数据处理**:将接收的数据传递给现有的数据处理管道
|
||||
- **实时显示**:在ECG曲线图上实时显示接收的数据
|
||||
|
||||
### 3. 用户界面
|
||||
- **状态指示**:按钮颜色和文字显示当前连接状态
|
||||
- **日志记录**:详细记录蓝牙操作和状态变化
|
||||
- **错误处理**:友好的错误提示和状态恢复
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 第一步:启用蓝牙
|
||||
1. 确保设备蓝牙已启用
|
||||
2. 确保心电设备已开启并处于可发现状态
|
||||
|
||||
### 第二步:连接设备
|
||||
1. 点击"连接蓝牙"按钮
|
||||
2. 等待扫描完成(约2-3秒)
|
||||
3. 在弹出的设备列表中选择目标设备
|
||||
4. 等待连接建立
|
||||
|
||||
### 第三步:开始数据采集
|
||||
1. 连接成功后,点击"启动程序"按钮
|
||||
2. 观察ECG曲线图开始显示实时数据
|
||||
3. 查看状态信息确认数据接收正常
|
||||
|
||||
## 🔧 技术配置
|
||||
|
||||
### 蓝牙参数设置
|
||||
```kotlin
|
||||
// 服务UUID (根据设备协议调整)
|
||||
private val SERVICE_UUID = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// 特征UUID (根据设备协议调整)
|
||||
private val CHARACTERISTIC_UUID = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// 设备名称前缀
|
||||
private const val DEVICE_NAME_PREFIX = "ECG"
|
||||
```
|
||||
|
||||
### 权限配置
|
||||
```xml
|
||||
<!-- 蓝牙权限 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
```
|
||||
|
||||
## 📊 状态指示
|
||||
|
||||
### 按钮状态
|
||||
| 状态 | 按钮文字 | 按钮颜色 | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| 未连接 | "连接蓝牙" | 绿色 | 可以开始连接 |
|
||||
| 扫描中 | "扫描中..." | 橙色 | 正在扫描设备 |
|
||||
| 连接中 | "连接中..." | 橙色 | 正在连接设备 |
|
||||
| 已连接 | "断开蓝牙" | 红色 | 可以断开连接 |
|
||||
|
||||
### 日志信息
|
||||
- `[时间] 蓝牙状态: 正在扫描蓝牙设备...`
|
||||
- `[时间] 发现设备: ECG_Device_001`
|
||||
- `[时间] 蓝牙设备已连接: ECG_Device_001`
|
||||
- `[时间] 接收到蓝牙数据: 128 字节`
|
||||
- `[时间] 蓝牙设备已断开`
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 蓝牙未启用
|
||||
**症状**:点击连接按钮无反应
|
||||
**解决**:在系统设置中启用蓝牙
|
||||
|
||||
#### 2. 权限不足
|
||||
**症状**:提示"缺少蓝牙扫描权限"
|
||||
**解决**:在应用设置中授予蓝牙权限
|
||||
|
||||
#### 3. 设备未发现
|
||||
**症状**:扫描完成但未找到设备
|
||||
**解决**:
|
||||
- 确认设备已开启
|
||||
- 确认设备处于可发现状态
|
||||
- 检查设备距离是否过远
|
||||
|
||||
#### 4. 连接失败
|
||||
**症状**:选择设备后连接失败
|
||||
**解决**:
|
||||
- 确认设备未被其他应用占用
|
||||
- 重新启动设备
|
||||
- 检查设备电池电量
|
||||
|
||||
### 调试信息
|
||||
应用会在日志中记录详细的调试信息,包括:
|
||||
- 蓝牙初始化状态
|
||||
- 设备扫描过程
|
||||
- 连接建立过程
|
||||
- 数据接收情况
|
||||
|
||||
## 🔄 数据流程
|
||||
|
||||
```
|
||||
蓝牙设备 → BluetoothManager → MainActivity → DataManager → 信号处理 → 指标计算 → UI显示
|
||||
```
|
||||
|
||||
1. **蓝牙设备**:发送原始心电数据
|
||||
2. **BluetoothManager**:接收数据并传递给应用
|
||||
3. **MainActivity**:处理蓝牙回调和数据转发
|
||||
4. **DataManager**:解析和处理数据
|
||||
5. **信号处理**:滤波和信号增强
|
||||
6. **指标计算**:计算心率和HRV指标
|
||||
7. **UI显示**:在曲线图上显示结果
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **首次使用**:需要授予蓝牙权限
|
||||
2. **设备兼容性**:确保设备支持BLE协议
|
||||
3. **数据格式**:确保设备发送的数据格式与解析器兼容
|
||||
4. **连接稳定性**:保持设备在有效范围内
|
||||
5. **电池管理**:注意设备电池电量,避免连接中断
|
||||
|
||||
## 🔮 未来改进
|
||||
|
||||
1. **自动重连**:连接断开时自动尝试重连
|
||||
2. **多设备支持**:同时连接多个设备
|
||||
3. **数据缓存**:本地缓存数据防止丢失
|
||||
4. **连接优化**:优化连接速度和稳定性
|
||||
5. **设备管理**:保存常用设备列表
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
# 蓝牙UUID调试指南
|
||||
|
||||
## 🎯 问题描述
|
||||
连接蓝牙设备后出现"未找到目标服务"错误,这是因为设备的UUID与预设的不匹配。
|
||||
|
||||
## 🔧 解决方案
|
||||
|
||||
### 1. 自动UUID匹配 ✅
|
||||
- **实现**:支持多种常见ECG设备UUID
|
||||
- **效果**:自动尝试匹配不同的服务UUID和特征UUID
|
||||
- **UUID列表**:
|
||||
```
|
||||
服务UUID:
|
||||
- 0000fff0-0000-1000-8000-00805f9b34fb (默认)
|
||||
- 0000ffe0-0000-1000-8000-00805f9b34fb (变体1)
|
||||
- 0000ffe5-0000-1000-8000-00805f9b34fb (变体2)
|
||||
- 0000ff00-0000-1000-8000-00805f9b34fb (变体3)
|
||||
- 0000ff10-0000-1000-8000-00805f9b34fb (变体4)
|
||||
|
||||
特征UUID:
|
||||
- 0000fff1-0000-1000-8000-00805f9b34fb (默认)
|
||||
- 0000ffe1-0000-1000-8000-00805f9b34fb (变体1)
|
||||
- 0000ffe6-0000-1000-8000-00805f9b34fb (变体2)
|
||||
- 0000ff01-0000-1000-8000-00805f9b34fb (变体3)
|
||||
- 0000ff11-0000-1000-8000-00805f9b34fb (变体4)
|
||||
```
|
||||
|
||||
### 2. 详细调试信息 ✅
|
||||
- **实现**:打印所有可用服务和特征
|
||||
- **效果**:显示设备实际提供的UUID
|
||||
- **日志输出**:
|
||||
```
|
||||
D/BluetoothManager: 设备提供的服务数量: 3
|
||||
D/BluetoothManager: 发现服务: 0000fff0-0000-1000-8000-00805f9b34fb
|
||||
D/BluetoothManager: 服务 0000fff0-0000-1000-8000-00805f9b34fb 的特征数量: 2
|
||||
D/BluetoothManager: 发现特征: 0000fff1-0000-1000-8000-00805f9b34fb
|
||||
D/BluetoothManager: 发现特征: 0000fff2-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:连接设备
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **选择你的ECG设备**
|
||||
3. **等待连接完成**
|
||||
|
||||
### 第二步:查看调试信息
|
||||
1. **观察Logcat输出**:
|
||||
```
|
||||
蓝牙状态: 服务发现成功
|
||||
发现服务: 0000fff0-0000-1000-8000-00805f9b34fb
|
||||
发现特征: 0000fff1-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
2. **检查状态信息**:
|
||||
- ✅ 如果显示"数据通道已建立,开始接收数据" → 成功
|
||||
- ❌ 如果显示"未找到匹配的服务或特征" → 需要手动配置
|
||||
|
||||
### 第三步:手动配置UUID(如果需要)
|
||||
如果自动匹配失败,请:
|
||||
|
||||
1. **查看可用服务UUID**:
|
||||
```
|
||||
状态信息: 可用服务: 0000xxxx-0000-1000-8000-00805f9b34fb, ...
|
||||
```
|
||||
|
||||
2. **记录你的设备UUID**:
|
||||
- 服务UUID: `0000xxxx-0000-1000-8000-00805f9b34fb`
|
||||
- 特征UUID: `0000yyyy-0000-1000-8000-00805f9b34fb`
|
||||
|
||||
3. **修改代码**:
|
||||
```kotlin
|
||||
private val SERVICE_UUIDS = listOf(
|
||||
UUID.fromString("你的服务UUID"), // 添加你的UUID
|
||||
UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"),
|
||||
// ... 其他UUID
|
||||
)
|
||||
```
|
||||
|
||||
## 📱 常见ECG设备UUID
|
||||
|
||||
### 1. 标准ECG设备
|
||||
```
|
||||
服务UUID: 0000fff0-0000-1000-8000-00805f9b34fb
|
||||
特征UUID: 0000fff1-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
### 2. 心电监护仪
|
||||
```
|
||||
服务UUID: 0000ffe0-0000-1000-8000-00805f9b34fb
|
||||
特征UUID: 0000ffe1-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
### 3. 便携式ECG
|
||||
```
|
||||
服务UUID: 0000ffe5-0000-1000-8000-00805f9b34fb
|
||||
特征UUID: 0000ffe6-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
## 🔍 调试技巧
|
||||
|
||||
### 1. 使用Logcat过滤
|
||||
```
|
||||
adb logcat | grep BluetoothManager
|
||||
```
|
||||
|
||||
### 2. 查看设备信息
|
||||
```
|
||||
adb logcat | grep "发现服务\|发现特征"
|
||||
```
|
||||
|
||||
### 3. 检查连接状态
|
||||
```
|
||||
adb logcat | grep "设备已连接\|服务发现成功"
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. UUID格式
|
||||
- UUID必须是标准格式:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||
- 16位UUID会自动扩展为128位
|
||||
|
||||
### 2. 权限要求
|
||||
- 需要`BLUETOOTH_CONNECT`权限
|
||||
- Android 12+需要额外权限
|
||||
|
||||
### 3. 设备兼容性
|
||||
- 不同厂商的ECG设备可能使用不同UUID
|
||||
- 需要根据实际设备调整
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 成功连接:
|
||||
- ✅ 显示"数据通道已建立,开始接收数据"
|
||||
- ✅ 开始接收ECG数据
|
||||
- ✅ 数据传递给数据处理模块
|
||||
|
||||
### 需要调试:
|
||||
- ❌ 显示"未找到匹配的服务或特征"
|
||||
- ℹ️ 显示所有可用服务的UUID
|
||||
- 🔧 需要手动添加正确的UUID
|
||||
|
||||
现在请重新连接你的设备,查看调试信息,告诉我你的设备实际使用的UUID!🎯
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
# 按钮和对话框修复测试指南
|
||||
|
||||
## 🎯 修复内容
|
||||
|
||||
### 1. 按钮位置修复 ✅
|
||||
- **问题**:按钮在屏幕最上面,显示不全
|
||||
- **解决**:添加顶部边距,让按钮下移
|
||||
- **实现**:
|
||||
```xml
|
||||
android:layout_marginTop="50dp" <!-- 添加50dp顶部边距 -->
|
||||
```
|
||||
|
||||
### 2. 对话框性能优化 ✅
|
||||
- **问题**:设备选择对话框很卡
|
||||
- **解决**:简化对话框实现,限制设备数量
|
||||
- **实现**:
|
||||
- 限制最多显示8个设备
|
||||
- 使用简单的文本列表而不是ListView
|
||||
- 提供快速选择按钮
|
||||
|
||||
## 📱 现在的UI布局
|
||||
|
||||
### 启动时的界面
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [状态栏区域] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [连接蓝牙] [启动程序] │
|
||||
│ [停止程序] [陷波滤波] │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 状态信息区域 │
|
||||
│ [应用已就绪,可以开始使用] │
|
||||
│ [权限状态信息] │
|
||||
│ [点击"连接蓝牙"按钮开始蓝牙连接...] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 设备选择对话框
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 选择蓝牙设备 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 找到 5 个设备: │
|
||||
│ │
|
||||
│ • iPhone (00:11:22:33:44:55) │
|
||||
│ • AirPods (AA:BB:CC:DD:EE:FF) │
|
||||
│ • 小米手环 (11:22:33:44:55:66) │
|
||||
│ • Samsung Galaxy (22:33:44:55:66:77) │
|
||||
│ • Huawei Watch (33:44:55:66:77:88) │
|
||||
│ │
|
||||
│ 请选择要连接的设备: │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [选择第一个设备] [选择第二个设备] [取消] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:验证按钮位置
|
||||
1. **启动应用**
|
||||
2. **确认按钮位置**:
|
||||
- ✅ 按钮不在屏幕最上面
|
||||
- ✅ 按钮完全可见
|
||||
- ✅ 按钮与状态栏有足够距离
|
||||
|
||||
### 第二步:测试蓝牙扫描
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **观察扫描过程**:
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: iPhone (00:11:22:33:44:55)
|
||||
发现设备: AirPods (AA:BB:CC:DD:EE:FF)
|
||||
发现设备: 小米手环 (11:22:33:44:55:66)
|
||||
```
|
||||
|
||||
### 第三步:验证对话框性能
|
||||
1. **扫描完成后**:
|
||||
- ✅ 对话框应该快速弹出
|
||||
- ✅ 对话框不卡顿
|
||||
- ✅ 设备列表清晰显示
|
||||
|
||||
### 第四步:测试设备选择
|
||||
1. **选择设备**:
|
||||
- 点击"选择第一个设备"按钮
|
||||
- 或点击"选择第二个设备"按钮
|
||||
- 观察连接过程
|
||||
|
||||
### 第五步:验证连接状态
|
||||
1. **连接成功后**:
|
||||
- ✅ 按钮文字变为"断开蓝牙"
|
||||
- ✅ 按钮颜色变为红色
|
||||
- ✅ 状态信息更新
|
||||
|
||||
## 🔧 关键代码修改
|
||||
|
||||
### 1. 按钮位置修复
|
||||
```xml
|
||||
<!-- 控制按钮区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:background="#E8E8E8"
|
||||
android:layout_marginTop="50dp"> <!-- 添加顶部边距 -->
|
||||
```
|
||||
|
||||
### 2. 对话框性能优化
|
||||
```kotlin
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
builder.setTitle("选择蓝牙设备")
|
||||
|
||||
if (devices.isEmpty()) {
|
||||
builder.setMessage("未找到蓝牙设备")
|
||||
builder.setPositiveButton("重新扫描") { _, _ -> dismiss() }
|
||||
builder.setNegativeButton("取消") { _, _ -> dismiss() }
|
||||
} else {
|
||||
// 限制设备数量,避免列表过长
|
||||
val limitedDevices = devices.take(8) // 最多显示8个设备
|
||||
|
||||
// 创建设备列表字符串
|
||||
val deviceListText = limitedDevices.joinToString("\n") { device ->
|
||||
"• ${device.name ?: "未知设备"} (${device.address})"
|
||||
}
|
||||
|
||||
val message = if (devices.size > 8) {
|
||||
"找到 ${devices.size} 个设备,显示前8个:\n\n$deviceListText\n\n请选择要连接的设备:"
|
||||
} else {
|
||||
"找到 ${devices.size} 个设备:\n\n$deviceListText\n\n请选择要连接的设备:"
|
||||
}
|
||||
|
||||
builder.setMessage(message)
|
||||
|
||||
// 创建选择按钮
|
||||
builder.setPositiveButton("选择第一个设备") { _, _ ->
|
||||
if (limitedDevices.isNotEmpty()) {
|
||||
onDeviceSelectedListener?.invoke(limitedDevices[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有多个设备,添加更多选择按钮
|
||||
if (limitedDevices.size > 1) {
|
||||
builder.setNeutralButton("选择第二个设备") { _, _ ->
|
||||
onDeviceSelectedListener?.invoke(limitedDevices[1])
|
||||
}
|
||||
}
|
||||
|
||||
builder.setNegativeButton("取消") { _, _ -> dismiss() }
|
||||
}
|
||||
|
||||
return builder.create()
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 按钮位置
|
||||
- 添加了50dp的顶部边距
|
||||
- 确保按钮在状态栏下方
|
||||
- 适应不同屏幕尺寸
|
||||
|
||||
### 2. 对话框性能
|
||||
- 限制最多显示8个设备
|
||||
- 使用简单的文本列表
|
||||
- 提供快速选择按钮
|
||||
|
||||
### 3. 设备选择
|
||||
- 第一个设备:点击"选择第一个设备"
|
||||
- 第二个设备:点击"选择第二个设备"
|
||||
- 更多设备:可以修改代码添加更多按钮
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 启动时:
|
||||
- ✅ 按钮位置合理,完全可见
|
||||
- ✅ 界面布局清晰
|
||||
- ✅ 状态信息正常显示
|
||||
|
||||
### 扫描时:
|
||||
- ✅ 扫描过程流畅
|
||||
- ✅ 设备发现信息及时更新
|
||||
|
||||
### 对话框:
|
||||
- ✅ 快速弹出,无卡顿
|
||||
- ✅ 设备列表清晰显示
|
||||
- ✅ 选择按钮响应及时
|
||||
|
||||
### 连接后:
|
||||
- ✅ 连接状态正确显示
|
||||
- ✅ 按钮状态正确更新
|
||||
- ✅ 可以开始数据处理
|
||||
|
||||
现在可以测试修复后的按钮位置和对话框性能了!🎉
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
# 设备选择功能改进测试指南
|
||||
|
||||
## 🎯 改进内容
|
||||
|
||||
### 1. 显示所有扫描到的设备 ✅
|
||||
- **问题**:之前只显示前8个设备
|
||||
- **解决**:使用RecyclerView显示所有扫描到的设备
|
||||
- **实现**:移除设备数量限制,显示完整设备列表
|
||||
|
||||
### 2. 点击任意设备连接 ✅
|
||||
- **问题**:之前只能选择前两个设备
|
||||
- **解决**:每个设备都可以点击连接
|
||||
- **实现**:RecyclerView的每个item都可以点击
|
||||
|
||||
### 3. 扫描完成后立即弹出对话框 ✅
|
||||
- **问题**:扫描完成后可能延迟弹出对话框
|
||||
- **解决**:扫描停止时立即触发回调
|
||||
- **实现**:在`stopBleScan()`中立即调用`onScanComplete`
|
||||
|
||||
## 📱 现在的UI布局
|
||||
|
||||
### 设备选择对话框
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 选择蓝牙设备 (找到 5 个设备) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ iPhone │ │
|
||||
│ │ 00:11:22:33:44:55 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ AirPods │ │
|
||||
│ │ AA:BB:CC:DD:EE:FF │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 小米手环 │ │
|
||||
│ │ 11:22:33:44:55:66 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Samsung Galaxy │ │
|
||||
│ │ 22:33:44:55:66:77 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Huawei Watch │ │
|
||||
│ │ 33:44:55:66:77:88 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [取消] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:启动蓝牙扫描
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **观察扫描过程**:
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: iPhone (00:11:22:33:44:55)
|
||||
发现设备: AirPods (AA:BB:CC:DD:EE:FF)
|
||||
发现设备: 小米手环 (11:22:33:44:55:66)
|
||||
```
|
||||
|
||||
### 第二步:验证扫描完成提示
|
||||
1. **扫描完成后**:
|
||||
- ✅ 显示"扫描完成,找到 X 个设备"
|
||||
- ✅ 立即弹出设备选择对话框
|
||||
- ✅ 对话框标题显示设备数量
|
||||
|
||||
### 第三步:验证设备列表
|
||||
1. **检查设备列表**:
|
||||
- ✅ 显示所有扫描到的设备
|
||||
- ✅ 每个设备显示名称和地址
|
||||
- ✅ 列表可以滚动(如果设备很多)
|
||||
|
||||
### 第四步:测试设备选择
|
||||
1. **点击任意设备**:
|
||||
- ✅ 点击第一个设备
|
||||
- ✅ 点击中间的设备
|
||||
- ✅ 点击最后一个设备
|
||||
- ✅ 每个设备都能正常连接
|
||||
|
||||
### 第五步:验证连接过程
|
||||
1. **连接状态变化**:
|
||||
- ✅ 按钮文字变为"连接中..."
|
||||
- ✅ 按钮颜色变为橙色
|
||||
- ✅ 状态信息更新
|
||||
|
||||
## 🔧 关键代码修改
|
||||
|
||||
### 1. 设备适配器
|
||||
```kotlin
|
||||
private inner class DeviceAdapter : RecyclerView.Adapter<DeviceAdapter.DeviceViewHolder>() {
|
||||
|
||||
override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) {
|
||||
val device = devices[position]
|
||||
val deviceName = device.name ?: "未知设备"
|
||||
val deviceAddress = device.address
|
||||
holder.textView.text = "$deviceName\n$deviceAddress"
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
onDeviceSelectedListener?.invoke(device)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = devices.size
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 对话框实现
|
||||
```kotlin
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
builder.setTitle("选择蓝牙设备 (找到 ${devices.size} 个设备)")
|
||||
|
||||
if (devices.isEmpty()) {
|
||||
builder.setMessage("未找到蓝牙设备")
|
||||
builder.setPositiveButton("重新扫描") { _, _ -> dismiss() }
|
||||
builder.setNegativeButton("取消") { _, _ -> dismiss() }
|
||||
} else {
|
||||
// 创建RecyclerView显示所有设备
|
||||
val recyclerView = RecyclerView(requireContext()).apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = DeviceAdapter()
|
||||
|
||||
// 设置固定高度,避免对话框过大
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
600 // 固定高度600dp
|
||||
)
|
||||
}
|
||||
|
||||
builder.setView(recyclerView)
|
||||
builder.setNegativeButton("取消") { _, _ -> dismiss() }
|
||||
}
|
||||
|
||||
return builder.create()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 扫描完成回调
|
||||
```kotlin
|
||||
override fun onScanComplete(devices: List<BluetoothDevice>) {
|
||||
runOnUiThread {
|
||||
updateStatus("扫描完成,找到 ${devices.size} 个设备")
|
||||
|
||||
if (devices.isNotEmpty()) {
|
||||
// 立即显示设备选择对话框
|
||||
val dialog = BluetoothDeviceDialog.newInstance(devices)
|
||||
dialog.setOnDeviceSelectedListener { device ->
|
||||
// 连接选中的设备
|
||||
bluetoothManager.connectToDevice(device)
|
||||
binding.bluetoothButton.text = "连接中..."
|
||||
binding.bluetoothButton.setBackgroundColor(Color.parseColor("#FF9800"))
|
||||
updateStatus("正在连接设备: ${device.name ?: device.address}")
|
||||
}
|
||||
dialog.show(supportFragmentManager, "BluetoothDeviceDialog")
|
||||
} else {
|
||||
updateStatus("未找到蓝牙设备,请重试")
|
||||
binding.bluetoothButton.text = "连接蓝牙"
|
||||
binding.bluetoothButton.setBackgroundColor(Color.parseColor("#4CAF50"))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. BLE扫描停止
|
||||
```kotlin
|
||||
private fun stopBleScan() {
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(bleScanCallback)
|
||||
Log.d(TAG, "BLE扫描已停止")
|
||||
|
||||
// 扫描完成后立即触发回调
|
||||
callback?.onStatusChanged("扫描完成,找到 ${discoveredDevices.size} 个设备")
|
||||
callback?.onScanComplete(discoveredDevices.toList())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "停止BLE扫描失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 设备显示
|
||||
- 显示所有扫描到的设备,无数量限制
|
||||
- 每个设备显示名称和地址
|
||||
- 支持滚动查看(如果设备很多)
|
||||
|
||||
### 2. 连接选择
|
||||
- 点击任意设备都可以连接
|
||||
- 连接后对话框自动关闭
|
||||
- 支持取消操作
|
||||
|
||||
### 3. 性能优化
|
||||
- 使用RecyclerView提高性能
|
||||
- 固定对话框高度避免过大
|
||||
- 扫描完成后立即弹出
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 扫描过程:
|
||||
- ✅ 显示扫描进度
|
||||
- ✅ 实时显示发现的设备
|
||||
- ✅ 扫描完成后立即提示
|
||||
|
||||
### 设备选择:
|
||||
- ✅ 显示所有扫描到的设备
|
||||
- ✅ 每个设备都可以点击
|
||||
- ✅ 对话框响应流畅
|
||||
|
||||
### 连接过程:
|
||||
- ✅ 点击设备后立即开始连接
|
||||
- ✅ 连接状态正确显示
|
||||
- ✅ 支持连接任意设备
|
||||
|
||||
现在你可以扫描所有设备并点击任意设备进行连接了!🎉
|
||||
|
|
@ -0,0 +1,601 @@
|
|||
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.View
|
||||
import android.view.ScaleGestureDetector
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 可调节的通用生理信号图表视图
|
||||
* 支持多种设备类型和通道数量的动态显示,具有缩放、平移、时间窗口调节等功能
|
||||
*/
|
||||
class DynamicChartView @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 = false // 关闭抗锯齿,提高绘制性能
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
style = Paint.Style.STROKE
|
||||
alpha = 80
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = 20f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val controlPaint = Paint().apply {
|
||||
color = Color.RED
|
||||
strokeWidth = 3f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
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 isDataAvailable = false
|
||||
|
||||
// 图表配置
|
||||
private var chartTitle = "通道"
|
||||
private var channelIndex = 0
|
||||
private var deviceType = "未知设备"
|
||||
private var sampleRate = 250f // 默认采样率
|
||||
private var timeWindow = 4f // 默认时间窗口(秒)
|
||||
|
||||
// 性能优化:真正的逐点绘制
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
|
||||
// 多通道数据支持(用于PPG等设备)
|
||||
private val multiChannelDataPoints = mutableListOf<MutableList<Float>>()
|
||||
private val multiChannelDataBuffers = mutableListOf<MutableList<Float>>()
|
||||
private var isMultiChannelMode = false
|
||||
private val channelColors = listOf(Color.RED, Color.BLUE) // 通道颜色
|
||||
private var lastUpdateTime = 0L
|
||||
private val updateInterval = 2L // 2ms更新间隔,实现500Hz显示
|
||||
private var lastDrawTime = 0L
|
||||
private val drawInterval = 1L // 1ms绘制间隔,实现1000Hz绘制频率
|
||||
|
||||
// 交互控制参数
|
||||
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())
|
||||
|
||||
// 控制按钮区域
|
||||
private val controlButtonRect = RectF()
|
||||
private val fullscreenButtonRect = RectF()
|
||||
|
||||
// 全屏模式相关
|
||||
private var isFullscreenMode = false
|
||||
private var originalLayoutParams: android.view.ViewGroup.LayoutParams? = null
|
||||
|
||||
/**
|
||||
* 设置图表配置
|
||||
*/
|
||||
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
|
||||
resetViewport()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置多通道模式(用于PPG等设备)
|
||||
*/
|
||||
fun setMultiChannelMode(channelCount: Int) {
|
||||
isMultiChannelMode = true
|
||||
multiChannelDataPoints.clear()
|
||||
multiChannelDataBuffers.clear()
|
||||
|
||||
// 初始化每个通道的数据结构
|
||||
for (i in 0 until channelCount) {
|
||||
multiChannelDataPoints.add(mutableListOf())
|
||||
multiChannelDataBuffers.add(mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 批量添加到缓冲区
|
||||
dataBuffer.addAll(newData)
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 批量处理 - 每次处理更多点,提高数据累积速度
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
// 每次处理多个点,提高效率
|
||||
val batchSize = minOf(10, dataBuffer.size) // 每次最多处理10个点
|
||||
val batch = dataBuffer.take(batchSize)
|
||||
dataBuffer.removeAll(batch)
|
||||
|
||||
dataPoints.addAll(batch)
|
||||
|
||||
// 限制数据点数量
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 更新数据范围 - 使用批量数据
|
||||
updateDataRange(batch)
|
||||
|
||||
lastUpdateTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
// 超高频绘制
|
||||
if (currentTime - lastDrawTime >= drawInterval) {
|
||||
invalidate()
|
||||
lastDrawTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新多通道数据(用于PPG等设备)
|
||||
*/
|
||||
fun updateMultiChannelData(channelData: List<List<Float>>) {
|
||||
if (channelData.isEmpty() || !isMultiChannelMode) {
|
||||
Log.d("DynamicChartView", "跳过多通道更新: channelData.isEmpty=${channelData.isEmpty()}, isMultiChannelMode=$isMultiChannelMode")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("DynamicChartView", "多通道数据更新: 通道数=${channelData.size}, 每个通道数据点=${channelData.map { it.size }}")
|
||||
isDataAvailable = true
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 更新每个通道的数据
|
||||
for (channelIndex in channelData.indices) {
|
||||
if (channelIndex >= multiChannelDataBuffers.size) {
|
||||
Log.w("DynamicChartView", "通道索引超出范围: $channelIndex >= ${multiChannelDataBuffers.size}")
|
||||
continue
|
||||
}
|
||||
|
||||
val channelBuffer = multiChannelDataBuffers[channelIndex]
|
||||
channelBuffer.addAll(channelData[channelIndex])
|
||||
Log.d("DynamicChartView", "通道$channelIndex 添加了${channelData[channelIndex].size}个数据点")
|
||||
}
|
||||
|
||||
// 批量处理数据
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
var hasDataToProcess = false
|
||||
|
||||
for (channelIndex in multiChannelDataBuffers.indices) {
|
||||
val buffer = multiChannelDataBuffers[channelIndex]
|
||||
if (buffer.isNotEmpty()) {
|
||||
// 每次处理多个点
|
||||
val batchSize = minOf(10, buffer.size)
|
||||
val batch = buffer.take(batchSize)
|
||||
buffer.removeAll(batch)
|
||||
|
||||
multiChannelDataPoints[channelIndex].addAll(batch)
|
||||
|
||||
// 限制数据点数量
|
||||
if (multiChannelDataPoints[channelIndex].size > maxDataPoints) {
|
||||
multiChannelDataPoints[channelIndex] = multiChannelDataPoints[channelIndex].takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
hasDataToProcess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDataToProcess) {
|
||||
// 更新数据范围 - 计算所有通道的数据范围
|
||||
updateMultiChannelDataRange()
|
||||
lastUpdateTime = currentTime
|
||||
|
||||
Log.d("DynamicChartView", "多通道数据处理完成: 通道数据点=${multiChannelDataPoints.map { it.size }}, 范围=${minValue}-${maxValue}")
|
||||
}
|
||||
}
|
||||
|
||||
// 超高频绘制
|
||||
if (currentTime - lastDrawTime >= drawInterval) {
|
||||
invalidate()
|
||||
lastDrawTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDataRange(batch: 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 {
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新多通道数据范围
|
||||
*/
|
||||
private fun updateMultiChannelDataRange() {
|
||||
if (multiChannelDataPoints.isEmpty()) {
|
||||
Log.w("DynamicChartView", "多通道数据点为空,无法计算范围")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算所有通道的最小值和最大值
|
||||
var globalMin = Float.MAX_VALUE
|
||||
var globalMax = Float.MIN_VALUE
|
||||
var hasValidData = false
|
||||
|
||||
for (channelIndex in multiChannelDataPoints.indices) {
|
||||
val channelData = multiChannelDataPoints[channelIndex]
|
||||
if (channelData.isNotEmpty()) {
|
||||
val channelMin = channelData.minOrNull() ?: 0f
|
||||
val channelMax = channelData.maxOrNull() ?: 0f
|
||||
globalMin = minOf(globalMin, channelMin)
|
||||
globalMax = maxOf(globalMax, channelMax)
|
||||
hasValidData = true
|
||||
Log.d("DynamicChartView", "通道$channelIndex: 范围=${channelMin}-${channelMax}, 数据点=${channelData.size}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidData) {
|
||||
Log.w("DynamicChartView", "没有有效的多通道数据")
|
||||
return
|
||||
}
|
||||
|
||||
minValue = globalMin
|
||||
maxValue = globalMax
|
||||
|
||||
Log.d("DynamicChartView", "多通道全局范围: ${minValue}-${maxValue}")
|
||||
|
||||
// 确保有足够的显示范围
|
||||
val range = maxValue - minValue
|
||||
if (range < 0.1f) {
|
||||
val center = (maxValue + minValue) / 2
|
||||
minValue = center - 0.05f
|
||||
maxValue = center + 0.05f
|
||||
} else {
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
|
||||
Log.d("DynamicChartView", "多通道显示范围: ${minValue}-${maxValue}")
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
dataPoints.clear()
|
||||
dataBuffer.clear()
|
||||
// 清除多通道数据
|
||||
for (channelData in multiChannelDataPoints) {
|
||||
channelData.clear()
|
||||
}
|
||||
for (channelBuffer in multiChannelDataBuffers) {
|
||||
channelBuffer.clear()
|
||||
}
|
||||
isDataAvailable = false
|
||||
resetViewport()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置视口到默认状态
|
||||
*/
|
||||
fun resetViewport() {
|
||||
scaleFactor = 1.0f
|
||||
translateX = 0f
|
||||
translateY = 0f
|
||||
viewportStart = 0f
|
||||
viewportEnd = 1.0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
|
||||
// 开始拖拽
|
||||
isDragging = true
|
||||
lastTouchX = x
|
||||
lastTouchY = y
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (isDragging) {
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
|
||||
translateX += deltaX
|
||||
translateY += deltaY
|
||||
|
||||
// 限制平移范围
|
||||
val maxTranslate = width * 0.5f
|
||||
translateX = translateX.coerceIn(-maxTranslate, maxTranslate)
|
||||
translateY = translateY.coerceIn(-maxTranslate, maxTranslate)
|
||||
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
isDragging = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
scaleFactor *= detector.scaleFactor
|
||||
scaleFactor = scaleFactor.coerceIn(0.2f, 5.0f)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 应用变换
|
||||
canvas.save()
|
||||
canvas.translate(translateX, translateY)
|
||||
canvas.scale(scaleFactor, scaleFactor, width / 2, height / 2)
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制标题和通道信息
|
||||
drawTitle(canvas, width, height)
|
||||
|
||||
// 绘制数据统计
|
||||
drawStats(canvas, width, height)
|
||||
|
||||
// 绘制全屏按钮
|
||||
drawFullscreenButton(canvas, width, height)
|
||||
|
||||
// 如果没有数据,显示默认内容
|
||||
if (!isDataAvailable) {
|
||||
drawDefaultContent(canvas, width, height)
|
||||
} else {
|
||||
// 绘制曲线 - 检查单通道或多通道数据
|
||||
val hasDataToDraw = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.any { it.size > 1 }
|
||||
} else {
|
||||
dataPoints.size > 1
|
||||
}
|
||||
|
||||
if (hasDataToDraw) {
|
||||
drawCurve(canvas, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
val padding = 60f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
// 绘制水平网格线
|
||||
val gridLines = 5
|
||||
for (i in 0..gridLines) {
|
||||
val y = padding + (drawHeight * i / gridLines)
|
||||
canvas.drawLine(padding, y, width - padding, y, gridPaint)
|
||||
}
|
||||
|
||||
// 绘制垂直网格线
|
||||
for (i in 0..gridLines) {
|
||||
val x = padding + (drawWidth * i / gridLines)
|
||||
canvas.drawLine(x, padding, x, height - padding, gridPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawTitle(canvas: Canvas, width: Float, height: Float) {
|
||||
val title = "$chartTitle $channelIndex"
|
||||
canvas.drawText(title, 10f, 25f, textPaint)
|
||||
|
||||
val deviceInfo = "$deviceType (${sampleRate.toInt()}Hz)"
|
||||
canvas.drawText(deviceInfo, 10f, 50f, textPaint)
|
||||
|
||||
// 显示当前缩放和时间窗口信息
|
||||
val scaleInfo = "缩放: ${String.format("%.1f", scaleFactor)}x"
|
||||
canvas.drawText(scaleInfo, width - 150f, 25f, textPaint)
|
||||
|
||||
val timeInfo = "时间窗口: ${timeWindow.toInt()}秒"
|
||||
canvas.drawText(timeInfo, width - 150f, 50f, textPaint)
|
||||
}
|
||||
|
||||
private fun drawStats(canvas: Canvas, width: Float, height: Float) {
|
||||
val totalDataPoints = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.sumOf { it.size }
|
||||
} else {
|
||||
dataPoints.size
|
||||
}
|
||||
|
||||
val statsText = if (isMultiChannelMode) {
|
||||
"数据点: ${totalDataPoints} (${multiChannelDataPoints.size}通道)"
|
||||
} else {
|
||||
"数据点: ${dataPoints.size}"
|
||||
}
|
||||
canvas.drawText(statsText, 10f, height - 35f, textPaint)
|
||||
|
||||
if (totalDataPoints > 0) {
|
||||
val rangeText = "范围: ${String.format("%.2f", minValue)} - ${String.format("%.2f", maxValue)}"
|
||||
canvas.drawText(rangeText, 10f, height - 15f, textPaint)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 16f
|
||||
textAlign = Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示数据", centerX, centerY + 60f, hintPaint)
|
||||
}
|
||||
|
||||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||||
val padding = 60f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
if (isMultiChannelMode && multiChannelDataPoints.isNotEmpty()) {
|
||||
Log.d("DynamicChartView", "多通道绘制: 通道数=${multiChannelDataPoints.size}, 数据点=${multiChannelDataPoints.map { it.size }}")
|
||||
// 多通道绘制模式
|
||||
for (channelIndex in multiChannelDataPoints.indices) {
|
||||
val channelData = multiChannelDataPoints[channelIndex]
|
||||
if (channelData.isEmpty()) {
|
||||
Log.d("DynamicChartView", "通道$channelIndex 数据为空,跳过绘制")
|
||||
continue
|
||||
}
|
||||
|
||||
path.reset()
|
||||
|
||||
// 设置通道颜色
|
||||
val channelPaint = Paint(paint).apply {
|
||||
color = channelColors.getOrElse(channelIndex) { Color.GRAY }
|
||||
strokeWidth = 2f
|
||||
}
|
||||
|
||||
val xStep = if (channelData.size > 1) drawWidth / (channelData.size - 1) else drawWidth
|
||||
|
||||
for (i in channelData.indices) {
|
||||
val x = padding + i * xStep
|
||||
val normalizedValue = (channelData[i] - minValue) / (maxValue - minValue)
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, channelPaint)
|
||||
}
|
||||
} else {
|
||||
// 单通道绘制模式
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
path.reset()
|
||||
val xStep = if (dataPoints.size > 1) drawWidth / (dataPoints.size - 1) else drawWidth
|
||||
|
||||
for (i in dataPoints.indices) {
|
||||
val x = padding + i * xStep
|
||||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val desiredHeight = 200 // 每个图表的高度
|
||||
val height = resolveSize(desiredHeight, heightMeasureSpec)
|
||||
setMeasuredDimension(widthMeasureSpec, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图表数据
|
||||
*/
|
||||
fun getCurrentData(): List<Float> {
|
||||
return dataPoints.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据统计信息
|
||||
*/
|
||||
fun getDataStats(): Map<String, Any> {
|
||||
val totalDataPoints = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.sumOf { it.size }
|
||||
} else {
|
||||
dataPoints.size
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"dataPoints" to totalDataPoints,
|
||||
"multiChannelMode" to isMultiChannelMode,
|
||||
"channelCount" to if (isMultiChannelMode) multiChannelDataPoints.size else 1,
|
||||
"minValue" to minValue,
|
||||
"maxValue" to maxValue,
|
||||
"range" to (maxValue - minValue),
|
||||
"isDataAvailable" to isDataAvailable,
|
||||
"scaleFactor" to scaleFactor,
|
||||
"timeWindow" to timeWindow
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
# JNI函数名不匹配问题修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
在代码重构过程中,将原生方法从`MainActivity`移动到`DataManager`类后,出现了以下错误:
|
||||
|
||||
```
|
||||
Cannot resolve corresponding JNI function Java_com_example_cmake_1project_1test_DataManager_createStreamParser
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
### JNI函数命名规则
|
||||
|
||||
JNI函数名遵循以下格式:
|
||||
```
|
||||
Java_{包名}_{类名}_{方法名}
|
||||
```
|
||||
|
||||
其中:
|
||||
- 包名中的点(.)用下划线(_)替换
|
||||
- 类名中的下划线用`_1`替换
|
||||
|
||||
### 具体变化
|
||||
|
||||
**重构前的JNI函数名**(在MainActivity中):
|
||||
```
|
||||
Java_com_example_cmake_1project_1test_MainActivity_createStreamParser
|
||||
Java_com_example_cmake_1project_1test_MainActivity_destroyStreamParser
|
||||
Java_com_example_cmake_1project_1test_MainActivity_streamParserAppend
|
||||
Java_com_example_cmake_1project_1test_MainActivity_streamParserDrainPackets
|
||||
```
|
||||
|
||||
**重构后的JNI函数名**(在DataManager中):
|
||||
```
|
||||
Java_com_example_cmake_1project_1test_DataManager_createStreamParser
|
||||
Java_com_example_cmake_1project_1test_DataManager_destroyStreamParser
|
||||
Java_com_example_cmake_1project_1test_DataManager_streamParserAppend
|
||||
Java_com_example_cmake_1project_1test_DataManager_streamParserDrainPackets
|
||||
```
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:回调接口模式(已实现)
|
||||
|
||||
通过创建回调接口,让`DataManager`通过`MainActivity`来调用原生方法,保持原有的JNI函数名。
|
||||
|
||||
#### 实现步骤
|
||||
|
||||
1. **创建回调接口**
|
||||
```kotlin
|
||||
interface NativeMethodCallback {
|
||||
fun createStreamParser(): Long
|
||||
fun destroyStreamParser(handle: Long)
|
||||
fun streamParserAppend(handle: Long, chunk: ByteArray)
|
||||
fun streamParserDrainPackets(handle: Long): List<SensorData>?
|
||||
}
|
||||
```
|
||||
|
||||
2. **修改DataManager构造函数**
|
||||
```kotlin
|
||||
class DataManager(private val nativeCallback: NativeMethodCallback)
|
||||
```
|
||||
|
||||
3. **MainActivity实现接口**
|
||||
```kotlin
|
||||
class MainActivity : AppCompatActivity(), DataManager.NativeMethodCallback
|
||||
```
|
||||
|
||||
4. **保持原生方法在MainActivity中**
|
||||
```kotlin
|
||||
// 原生方法声明 - 保持原来的JNI函数名
|
||||
external fun createStreamParser(): Long
|
||||
external fun destroyStreamParser(handle: Long)
|
||||
external fun streamParserAppend(handle: Long, chunk: ByteArray)
|
||||
external fun streamParserDrainPackets(handle: Long): List<SensorData>?
|
||||
```
|
||||
|
||||
#### 优势
|
||||
- 保持原有的JNI函数名,无需修改C++代码
|
||||
- 维持了代码重构的架构优势
|
||||
- 清晰的职责分离
|
||||
|
||||
#### 劣势
|
||||
- 增加了接口依赖
|
||||
- 稍微增加了代码复杂度
|
||||
|
||||
### 方案2:修改C++代码中的JNI函数名
|
||||
|
||||
如果您有权限修改C++代码,可以将C++中的JNI函数名改为新的名称。
|
||||
|
||||
#### 需要修改的C++函数名
|
||||
```cpp
|
||||
// 原来的函数名
|
||||
JNIEXPORT jlong JNICALL Java_com_example_cmake_1project_1test_MainActivity_createStreamParser
|
||||
|
||||
// 改为
|
||||
JNIEXPORT jlong JNICALL Java_com_example_cmake_1project_1test_DataManager_createStreamParser
|
||||
```
|
||||
|
||||
#### 优势
|
||||
- 完全符合重构后的架构
|
||||
- 无需额外的接口层
|
||||
|
||||
#### 劣势
|
||||
- 需要修改C++代码
|
||||
- 可能影响其他依赖项目
|
||||
|
||||
## 推荐方案
|
||||
|
||||
**推荐使用方案1(回调接口模式)**,原因如下:
|
||||
|
||||
1. **无需修改C++代码**:保持现有C++代码的稳定性
|
||||
2. **维持重构优势**:仍然保持了代码的职责分离
|
||||
3. **向后兼容**:如果将来需要,可以轻松切换到方案2
|
||||
4. **风险较低**:不会引入新的编译或链接问题
|
||||
|
||||
## 修复后的架构
|
||||
|
||||
```
|
||||
MainActivity (实现NativeMethodCallback)
|
||||
↓
|
||||
DataManager (通过回调调用原生方法)
|
||||
↓
|
||||
原生C++库 (保持原有JNI函数名)
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **JNI函数名一致性**:确保Kotlin中的`external`方法名与C++中的JNI函数名完全匹配
|
||||
2. **库加载**:确保在`MainActivity`的`companion object`中正确加载原生库
|
||||
3. **接口实现**:MainActivity必须实现`NativeMethodCallback`接口的所有方法
|
||||
4. **依赖注入**:DataManager的构造函数现在需要传入回调接口实例
|
||||
|
||||
## 验证修复
|
||||
|
||||
修复完成后,您应该能够:
|
||||
1. 成功编译项目
|
||||
2. 不再看到JNI函数名不匹配的错误
|
||||
3. 保持原有的功能正常运行
|
||||
4. 享受重构后的代码结构优势
|
||||
88
MAC地址格式说明.md
88
MAC地址格式说明.md
|
|
@ -1,88 +0,0 @@
|
|||
# MAC地址格式说明
|
||||
|
||||
## 问题解决
|
||||
|
||||
您遇到的错误 "60-E9-AA-30-0B-0A is not a valid Bluetooth address" 是因为MAC地址格式问题。
|
||||
|
||||
## 正确的MAC地址格式
|
||||
|
||||
### 支持的格式:
|
||||
1. **冒号分隔符**(推荐):`60:E9:AA:30:8B:0A`
|
||||
2. **连字符分隔符**:`60-E9-AA-30-8B-0A`
|
||||
|
||||
### 您的电脑MAC地址:
|
||||
- **正确格式**:`60:E9:AA:30:8B:0A`
|
||||
- **注意**:您之前输入的是 `60-E9-AA-30-0B-0A`,其中 `0B` 应该是 `8B`
|
||||
|
||||
## 常见MAC地址格式错误
|
||||
|
||||
### 1. 分隔符错误
|
||||
- ❌ 错误:`60.E9.AA.30.8B.0A`(点号分隔符)
|
||||
- ❌ 错误:`60 E9 AA 30 8B 0A`(空格分隔符)
|
||||
- ✅ 正确:`60:E9:AA:30:8B:0A`(冒号分隔符)
|
||||
|
||||
### 2. 字符错误
|
||||
- ❌ 错误:`60-E9-AA-30-0B-0A`(0B 应该是 8B)
|
||||
- ✅ 正确:`60:E9:AA:30:8B:0A`
|
||||
|
||||
### 3. 长度错误
|
||||
- ❌ 错误:`60:E9:AA:30:8B`(缺少两位)
|
||||
- ❌ 错误:`60:E9:AA:30:8B:0A:FF`(多出两位)
|
||||
- ✅ 正确:`60:E9:AA:30:8B:0A`(6组,每组2位)
|
||||
|
||||
## 如何获取正确的MAC地址
|
||||
|
||||
### Windows系统:
|
||||
1. 打开命令提示符(cmd)
|
||||
2. 输入:`ipconfig /all`
|
||||
3. 查找"物理地址"或"Physical Address"
|
||||
4. 格式类似:`60-E9-AA-30-8B-0A`
|
||||
|
||||
### 转换为冒号格式:
|
||||
- 将连字符 `-` 替换为冒号 `:`
|
||||
- `60-E9-AA-30-8B-0A` → `60:E9:AA:30:8B:0A`
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 确认MAC地址
|
||||
1. 在Windows命令提示符中输入:`ipconfig /all`
|
||||
2. 找到您的蓝牙适配器的物理地址
|
||||
3. 确认地址格式正确
|
||||
|
||||
### 2. 使用应用连接
|
||||
1. 启动应用
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. 输入正确的MAC地址:`60:E9:AA:30:8B:0A`
|
||||
4. 点击"连接"
|
||||
|
||||
### 3. 验证连接
|
||||
1. 观察连接状态
|
||||
2. 查看日志信息
|
||||
3. 如果仍有问题,尝试扫描连接
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么需要冒号分隔符?
|
||||
A: Android蓝牙API更推荐使用冒号分隔符,兼容性更好。
|
||||
|
||||
### Q: 我的MAC地址是连字符格式怎么办?
|
||||
A: 应用现在支持两种格式,但推荐使用冒号格式。
|
||||
|
||||
### Q: 连接仍然失败怎么办?
|
||||
A:
|
||||
1. 确认MAC地址正确
|
||||
2. 检查蓝牙权限
|
||||
3. 确保设备在范围内
|
||||
4. 尝试扫描连接方式
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 查看日志:
|
||||
在Android Studio的Logcat中查看:
|
||||
- `BluetoothManager` 标签的详细错误信息
|
||||
- 确认MAC地址格式验证结果
|
||||
|
||||
### 测试连接:
|
||||
1. 先使用扫描功能找到设备
|
||||
2. 记录正确的MAC地址
|
||||
3. 再使用直接连接功能
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
# Nordic UART Service (NUS) 适配指南
|
||||
|
||||
## 🎯 设备协议识别
|
||||
|
||||
从你的设备日志可以看出,你的ECG设备使用的是 **Nordic UART Service (NUS)** 协议:
|
||||
|
||||
```
|
||||
发现服务: 6e400001-b5a3-f393-e0a9-68716563686f // NUS Service
|
||||
发现特征: 6e400002-b5a3-f393-e0a9-68716563686f // NUS TX Characteristic
|
||||
```
|
||||
|
||||
### Nordic UART Service 协议说明
|
||||
|
||||
**NUS (Nordic UART Service)** 是一个标准的BLE服务,常用于:
|
||||
- ECG设备数据传输
|
||||
- 传感器数据流
|
||||
- 串口通信模拟
|
||||
- 医疗设备通信
|
||||
|
||||
### UUID 含义
|
||||
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-68716563686f`
|
||||
- 这是Nordic UART Service的标准UUID
|
||||
- 用于标识NUS服务
|
||||
|
||||
- **TX Characteristic UUID**: `6e400002-b5a3-f393-e0a9-68716563686f`
|
||||
- 这是NUS的TX(发送)特征
|
||||
- 用于接收设备发送的数据
|
||||
- 支持通知(Notify)属性
|
||||
|
||||
## 🔧 适配完成
|
||||
|
||||
### 1. UUID配置 ✅
|
||||
```kotlin
|
||||
// 服务UUID - 已添加到列表首位
|
||||
UUID.fromString("6e400001-b5a3-f393-e0a9-68716563686f")
|
||||
|
||||
// 特征UUID - 已添加到列表首位
|
||||
UUID.fromString("6e400002-b5a3-f393-e0a9-68716563686f")
|
||||
```
|
||||
|
||||
### 2. 优先级设置 ✅
|
||||
- **NUS UUID已放在列表首位**
|
||||
- **优先匹配你的设备协议**
|
||||
- **确保快速连接**
|
||||
|
||||
### 3. 数据接收 ✅
|
||||
- **自动启用特征通知**
|
||||
- **开始接收ECG数据流**
|
||||
- **数据传递给处理模块**
|
||||
|
||||
## 📱 测试步骤
|
||||
|
||||
### 第一步:重新连接设备
|
||||
1. **断开当前连接**
|
||||
2. **重新点击"连接蓝牙"**
|
||||
3. **选择你的设备**
|
||||
|
||||
### 第二步:验证连接
|
||||
1. **观察连接过程**:
|
||||
```
|
||||
蓝牙状态: 设备已连接
|
||||
蓝牙状态: 服务发现成功
|
||||
蓝牙状态: 发现服务: 6e400001-b5a3-f393-e0a9-68716563686f
|
||||
蓝牙状态: 发现特征: 6e400002-b5a3-f393-e0a9-68716563686f
|
||||
```
|
||||
|
||||
2. **检查UUID匹配**:
|
||||
```
|
||||
找到匹配的服务: 6e400001-b5a3-f393-e0a9-68716563686f
|
||||
找到匹配的特征: 6e400002-b5a3-f393-e0a9-68716563686f
|
||||
已启用特征通知
|
||||
数据通道已建立,开始接收数据
|
||||
```
|
||||
|
||||
### 第三步:验证数据接收
|
||||
1. **检查状态信息**:
|
||||
- ✅ "数据通道已建立,开始接收数据"
|
||||
- ✅ "接收到数据: X 字节"
|
||||
|
||||
2. **检查ECG图表**:
|
||||
- ✅ 图表变为可见
|
||||
- ✅ 开始显示实时波形
|
||||
|
||||
## 🔍 Nordic UART Service 特点
|
||||
|
||||
### 1. 数据格式
|
||||
- **原始字节流**:设备直接发送原始数据
|
||||
- **无协议头**:通常没有复杂的协议封装
|
||||
- **连续传输**:数据流连续传输
|
||||
|
||||
### 2. 常见用途
|
||||
- **ECG数据**:心电信号实时传输
|
||||
- **传感器数据**:各种生理参数
|
||||
- **控制命令**:设备控制指令
|
||||
|
||||
### 3. 优势
|
||||
- **标准化**:广泛使用的标准协议
|
||||
- **兼容性好**:支持多种设备
|
||||
- **实时性**:低延迟数据传输
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 连接成功:
|
||||
- ✅ 立即找到匹配的NUS服务
|
||||
- ✅ 快速建立数据通道
|
||||
- ✅ 开始接收ECG数据
|
||||
|
||||
### 数据处理:
|
||||
- ✅ 数据传递给DataManager
|
||||
- ✅ 进行通道映射和滤波
|
||||
- ✅ 显示实时ECG波形
|
||||
|
||||
### 应用功能:
|
||||
- ✅ 实时心率计算
|
||||
- ✅ 信号质量评估
|
||||
- ✅ 12导联ECG分析
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据格式
|
||||
- NUS通常发送原始数据
|
||||
- 可能需要特定的数据解析逻辑
|
||||
- 注意字节序和数据对齐
|
||||
|
||||
### 2. 设备状态
|
||||
- 确保设备处于数据发送状态
|
||||
- 某些设备需要特定命令激活数据流
|
||||
- 检查设备电池和连接状态
|
||||
|
||||
### 3. 数据质量
|
||||
- 监控数据接收频率
|
||||
- 检查数据包大小是否合理
|
||||
- 观察信号质量指标
|
||||
|
||||
现在请重新连接你的设备,应该能够成功建立数据通道并开始接收ECG数据了!🎯
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
# 蓝牙权限申请指南
|
||||
|
||||
## 🔐 权限说明
|
||||
|
||||
在Android 12及以上版本,使用蓝牙功能需要动态申请以下权限:
|
||||
|
||||
### 必需权限
|
||||
1. **BLUETOOTH_SCAN** - 扫描蓝牙设备
|
||||
2. **BLUETOOTH_CONNECT** - 连接蓝牙设备
|
||||
3. **ACCESS_FINE_LOCATION** - 精确位置(蓝牙扫描需要)
|
||||
4. **ACCESS_COARSE_LOCATION** - 粗略位置(蓝牙扫描需要)
|
||||
|
||||
## 📱 权限申请流程
|
||||
|
||||
### 首次启动应用
|
||||
1. 应用启动时会显示当前权限状态
|
||||
2. 点击"连接蓝牙"按钮
|
||||
3. 系统弹出权限申请对话框
|
||||
4. 选择"允许"授予所有权限
|
||||
|
||||
### 权限状态显示
|
||||
```
|
||||
权限状态:
|
||||
BLUETOOTH_SCAN: ✓
|
||||
BLUETOOTH_CONNECT: ✓
|
||||
ACCESS_FINE_LOCATION: ✓
|
||||
ACCESS_COARSE_LOCATION: ✓
|
||||
```
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### 1. 权限被拒绝
|
||||
**症状**:按钮显示"权限被拒绝",无法使用蓝牙功能
|
||||
**解决**:
|
||||
- 进入系统设置 → 应用管理 → 本应用 → 权限
|
||||
- 手动开启蓝牙和位置权限
|
||||
- 重新启动应用
|
||||
|
||||
### 2. 部分权限缺失
|
||||
**症状**:提示"缺少权限: BLUETOOTH_SCAN, ACCESS_FINE_LOCATION"
|
||||
**解决**:
|
||||
- 确保所有4个权限都已授予
|
||||
- 位置权限对于蓝牙扫描是必需的
|
||||
|
||||
### 3. 权限申请对话框不出现
|
||||
**症状**:点击按钮无反应
|
||||
**解决**:
|
||||
- 检查应用是否被系统限制
|
||||
- 重启应用或设备
|
||||
- 检查系统版本是否支持
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 权限检查代码
|
||||
```kotlin
|
||||
private fun checkAndRequestPermissions(): Boolean {
|
||||
val missingPermissions = mutableListOf<String>()
|
||||
|
||||
for (permission in REQUIRED_PERMISSIONS) {
|
||||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
missingPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
updateStatus("需要蓝牙权限: ${missingPermissions.joinToString(", ")}")
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
missingPermissions.toTypedArray(),
|
||||
PERMISSION_REQUEST_CODE
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### 权限结果处理
|
||||
```kotlin
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
when (requestCode) {
|
||||
PERMISSION_REQUEST_CODE -> {
|
||||
val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
if (allGranted) {
|
||||
updateStatus("蓝牙权限已授予,开始扫描...")
|
||||
startBluetoothScan()
|
||||
} else {
|
||||
updateStatus("蓝牙权限被拒绝,无法使用蓝牙功能")
|
||||
binding.bluetoothButton.text = "权限被拒绝"
|
||||
binding.bluetoothButton.setBackgroundColor(Color.parseColor("#9E9E9E"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 权限清单
|
||||
|
||||
### AndroidManifest.xml
|
||||
```xml
|
||||
<!-- 蓝牙权限 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- 蓝牙功能声明 -->
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
```
|
||||
|
||||
### MainActivity.kt
|
||||
```kotlin
|
||||
companion object {
|
||||
private const val PERMISSION_REQUEST_CODE = 1001
|
||||
private val REQUIRED_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
|
||||
// Used to load the 'cmake_project_test' library on application startup.
|
||||
init {
|
||||
System.loadLibrary("cmake_project_test")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 权限申请时机
|
||||
- 在用户主动点击蓝牙功能时申请
|
||||
- 避免应用启动时强制申请
|
||||
- 提供清晰的权限用途说明
|
||||
|
||||
### 2. 用户体验
|
||||
- 显示当前权限状态
|
||||
- 提供权限被拒绝时的解决方案
|
||||
- 友好的错误提示
|
||||
|
||||
### 3. 兼容性
|
||||
- 支持Android 6.0+的动态权限
|
||||
- 兼容Android 12+的新蓝牙权限
|
||||
- 处理权限被拒绝的情况
|
||||
|
||||
## 🔍 调试技巧
|
||||
|
||||
### 检查权限状态
|
||||
```kotlin
|
||||
private fun checkPermissionStatus(): String {
|
||||
val status = mutableListOf<String>()
|
||||
|
||||
for (permission in REQUIRED_PERMISSIONS) {
|
||||
val granted = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
status.add("${permission.split(".").last()}: ${if (granted) "✓" else "✗"}")
|
||||
}
|
||||
|
||||
return status.joinToString("\n")
|
||||
}
|
||||
```
|
||||
|
||||
### 日志输出
|
||||
- 权限申请过程会记录详细日志
|
||||
- 可通过Logcat查看权限状态变化
|
||||
- 便于调试权限相关问题
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **位置权限必需**:蓝牙扫描需要位置权限,这是系统要求
|
||||
2. **权限持久性**:权限一旦授予,应用重启后仍然有效
|
||||
3. **系统限制**:某些系统可能限制权限申请
|
||||
4. **版本兼容**:不同Android版本的权限要求可能不同
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# 蓝牙原始数据显示测试指南
|
||||
|
||||
## 🎯 功能说明
|
||||
|
||||
现在应用已经支持**立即显示蓝牙接收到的原始数据**,无需等待信号处理完成。
|
||||
|
||||
### 数据流处理顺序
|
||||
1. **蓝牙数据接收** → 立即解析
|
||||
2. **原始数据显示** → 立即画图(新增)
|
||||
3. **信号处理** → 后台处理
|
||||
4. **处理后数据显示** → 可选显示
|
||||
|
||||
## 📱 测试步骤
|
||||
|
||||
### 第一步:连接蓝牙设备
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **等待扫描完成**
|
||||
3. **选择你的ECG设备**
|
||||
4. **等待连接成功**
|
||||
|
||||
### 第二步:验证原始数据显示
|
||||
1. **观察状态信息**:
|
||||
```
|
||||
蓝牙状态: 设备已连接
|
||||
蓝牙状态: 服务发现成功
|
||||
蓝牙状态: 数据通道已建立,开始接收数据
|
||||
```
|
||||
|
||||
2. **检查数据接收日志**:
|
||||
```
|
||||
接收到蓝牙数据: X 字节
|
||||
解析出 X 个数据包
|
||||
立即发送原始数据到图表,处理 X 个数据包
|
||||
发送原始数据到通道 X,数据长度: X
|
||||
显示原始数据到图表,通道: X,数据长度: X
|
||||
```
|
||||
|
||||
3. **观察图表显示**:
|
||||
- ✅ ECG图表立即变为可见
|
||||
- ✅ 原始波形立即开始显示
|
||||
- ✅ 双视图同步更新(10秒节奏视图 + 2.5秒波形视图)
|
||||
|
||||
### 第三步:验证实时性
|
||||
1. **数据接收延迟**:应该几乎无延迟
|
||||
2. **图表更新频率**:与蓝牙数据接收频率一致
|
||||
3. **波形连续性**:原始数据应该连续显示
|
||||
|
||||
## 🔍 关键代码位置
|
||||
|
||||
### 1. 原始数据发送
|
||||
```kotlin
|
||||
// DataManager.kt - 第97行
|
||||
sendRawDataToCharts(packets)
|
||||
|
||||
// DataManager.kt - 第177行
|
||||
private fun sendRawDataToCharts(packets: List<SensorData>) {
|
||||
// 直接处理原始数据包,不进行通道映射
|
||||
for (packet in packets) {
|
||||
val channelData = packet.getChannelData()
|
||||
if (channelData != null) {
|
||||
for ((channelIndex, channel) in channelData.withIndex()) {
|
||||
// 立即发送原始数据到图表
|
||||
realTimeCallback?.onRawDataAvailable(channelIndex, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 原始数据显示
|
||||
```kotlin
|
||||
// MainActivity.kt - 第572行
|
||||
override fun onRawDataAvailable(channelIndex: Int, rawData: List<Float>) {
|
||||
// 立即显示原始数据到图表
|
||||
binding.ecgChartContainer.visibility = View.VISIBLE
|
||||
binding.ecgRhythmView.updateData(rawData)
|
||||
binding.ecgWaveformView.updateData(rawData)
|
||||
}
|
||||
```
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 连接成功时:
|
||||
- ✅ 蓝牙设备连接成功
|
||||
- ✅ 数据通道建立
|
||||
- ✅ 开始接收ECG数据
|
||||
|
||||
### 原始数据显示时:
|
||||
- ✅ ECG图表立即可见
|
||||
- ✅ 原始波形立即显示
|
||||
- ✅ 双视图同步更新
|
||||
- ✅ 无延迟实时显示
|
||||
|
||||
### 数据特点:
|
||||
- **原始性**:未经任何滤波处理
|
||||
- **实时性**:接收即显示
|
||||
- **连续性**:数据流连续
|
||||
- **完整性**:包含所有通道数据
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据格式
|
||||
- **支持的数据类型**:所有已定义的数据类型
|
||||
- **通道数量**:根据设备类型自动识别
|
||||
- **采样率**:根据数据类型自动设置
|
||||
|
||||
### 2. 显示特点
|
||||
- **原始信号**:包含噪声和基线漂移
|
||||
- **实时更新**:与蓝牙数据接收同步
|
||||
- **多通道**:支持多通道同时显示
|
||||
|
||||
### 3. 性能考虑
|
||||
- **UI线程**:原始数据显示在主线程
|
||||
- **内存使用**:图表自动管理数据缓冲
|
||||
- **更新频率**:与蓝牙数据接收频率一致
|
||||
|
||||
## 🔧 调试信息
|
||||
|
||||
### 关键日志
|
||||
```
|
||||
DataManager: 立即发送原始数据到图表,处理 X 个数据包
|
||||
DataManager: 发送原始数据到通道 X,数据长度: X
|
||||
MainActivity: 显示原始数据到图表,通道: X,数据长度: X
|
||||
```
|
||||
|
||||
### 状态检查
|
||||
- 蓝牙连接状态
|
||||
- 数据接收频率
|
||||
- 图表更新状态
|
||||
- 通道数据完整性
|
||||
|
||||
现在你的应用可以立即显示蓝牙接收到的原始数据了!连接设备后就能看到实时波形。🚀
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
# 代码重构说明
|
||||
|
||||
## 重构目标
|
||||
将原本堆在一个MainActivity.kt文件中的所有逻辑分解为多个职责明确的Kotlin文件,提高代码的可读性和可维护性。
|
||||
|
||||
## 重构后的文件结构
|
||||
|
||||
### 1. **MainActivity.kt** - 主界面控制器
|
||||
**职责**: 主界面逻辑和生命周期管理
|
||||
- 初始化各个管理器
|
||||
- 处理按钮点击事件
|
||||
- 管理Activity生命周期
|
||||
- 协调各个管理器之间的交互
|
||||
|
||||
**主要方法**:
|
||||
- `onCreate()`: 初始化UI和管理器
|
||||
- `onBleNotify()`: 蓝牙数据通知处理
|
||||
- `resetData()`: 重置数据
|
||||
- `reloadData()`: 重新加载数据
|
||||
|
||||
### 2. **Constants.kt** - 常量定义
|
||||
**职责**: 集中管理应用中的所有常量
|
||||
- UI更新间隔
|
||||
- 数据分块大小
|
||||
- 缓冲区管理阈值
|
||||
- 显示限制参数
|
||||
|
||||
**主要常量**:
|
||||
- `UPDATE_INTERVAL`: UI更新间隔(500ms)
|
||||
- `CHUNK_SIZE`: 数据分块大小(64字节)
|
||||
- `BUFFER_CLEANUP_THRESHOLD`: 缓冲区清理阈值(50)
|
||||
- `BUFFER_KEEP_COUNT`: 缓冲区保留数量(30)
|
||||
|
||||
### 3. **DeviceTypeHelper.kt** - 设备类型工具
|
||||
**职责**: 设备类型相关的工具方法
|
||||
- 设备类型名称映射
|
||||
- 通道数据详情构建
|
||||
|
||||
**主要方法**:
|
||||
- `getDeviceName()`: 根据数据类型获取设备名称
|
||||
- `buildChannelDetails()`: 构建通道数据详情字符串
|
||||
|
||||
### 4. **DataManager.kt** - 数据管理器
|
||||
**职责**: 数据解析、缓冲管理和原生方法调用
|
||||
- 管理流式解析器
|
||||
- 处理数据块
|
||||
- 管理数据缓冲区
|
||||
- 调用原生C++方法
|
||||
|
||||
**主要方法**:
|
||||
- `ensureParser()`: 确保解析器已创建
|
||||
- `onBleNotify()`: 处理蓝牙通知数据块
|
||||
- `processFileData()`: 处理文件数据
|
||||
- `cleanupBuffer()`: 智能清理缓冲区
|
||||
- `resetData()`: 重置所有数据
|
||||
|
||||
**原生方法**:
|
||||
- `createStreamParser()`: 创建流式解析器
|
||||
- `streamParserAppend()`: 向解析器追加数据
|
||||
- `streamParserDrainPackets()`: 从解析器拉取数据包
|
||||
- `destroyStreamParser()`: 销毁解析器
|
||||
|
||||
### 5. **UiManager.kt** - UI管理器
|
||||
**职责**: UI更新、统计信息构建和显示逻辑
|
||||
- 管理UI更新调度
|
||||
- 构建统计信息
|
||||
- 构建显示内容
|
||||
- 触发UI更新
|
||||
|
||||
**主要方法**:
|
||||
- `scheduleUiUpdate()`: 计划UI更新,避免频繁刷新
|
||||
- `buildStatisticsString()`: 构建统计信息字符串
|
||||
- `buildDisplayContent()`: 构建完整的显示内容
|
||||
- `updateDisplay()`: 更新UI显示内容
|
||||
|
||||
### 6. **FileHelper.kt** - 文件帮助类
|
||||
**职责**: 文件读取操作
|
||||
- 从assets文件夹读取文件
|
||||
|
||||
**主要方法**:
|
||||
- `readAssetFile()`: 读取assets文件到字节数组
|
||||
|
||||
## 重构优势
|
||||
|
||||
### 1. **职责分离**
|
||||
- 每个类都有明确的单一职责
|
||||
- 降低了类之间的耦合度
|
||||
- 提高了代码的可测试性
|
||||
|
||||
### 2. **代码复用**
|
||||
- 工具方法可以在多个地方复用
|
||||
- 减少了重复代码
|
||||
- 提高了开发效率
|
||||
|
||||
### 3. **维护性提升**
|
||||
- 修改某个功能只需要修改对应的类
|
||||
- 代码结构更清晰,易于理解
|
||||
- 降低了修改的风险
|
||||
|
||||
### 4. **可扩展性**
|
||||
- 新增功能可以创建新的类
|
||||
- 现有类可以独立扩展
|
||||
- 支持团队协作开发
|
||||
|
||||
## 数据流向
|
||||
|
||||
```
|
||||
MainActivity → DataManager → 原生解析器
|
||||
↓
|
||||
UiManager ← DataManager ← 数据缓冲区
|
||||
↓
|
||||
UI更新
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. **MainActivity**: 作为入口点,协调各个管理器
|
||||
2. **DataManager**: 处理所有数据相关的操作
|
||||
3. **UiManager**: 负责所有UI相关的更新
|
||||
4. **DeviceTypeHelper**: 提供设备类型相关的工具方法
|
||||
5. **Constants**: 集中管理所有常量
|
||||
6. **FileHelper**: 处理文件读取操作
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **原生方法**: 所有原生C++方法现在都在DataManager中声明
|
||||
2. **依赖关系**: 各个类之间有明确的依赖关系,避免循环依赖
|
||||
3. **线程安全**: UI更新始终在主线程进行,数据操作在后台线程进行
|
||||
4. **错误处理**: 每个类都有适当的错误处理和日志记录
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **依赖注入**: 可以考虑使用Dagger或Koin进行依赖注入
|
||||
2. **响应式编程**: 可以考虑使用RxJava或Flow进行数据流处理
|
||||
3. **单元测试**: 重构后的代码更容易进行单元测试
|
||||
4. **文档完善**: 可以添加更详细的API文档和示例代码
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
# 蓝牙指令使用说明
|
||||
|
||||
## 功能概述
|
||||
本应用支持通过蓝牙连接设备后,发送指令让设备开始发送单导联ECG数据,然后实时显示ECG曲线图。专门优化用于处理单导联蓝牙实时数据,不再读取12导联文件数据。
|
||||
|
||||
## 支持的协议
|
||||
|
||||
### 轻迅蓝牙通信协议 V1.0.1
|
||||
本应用完全支持轻迅蓝牙通信协议,包括:
|
||||
- 数据服务 UUID: `6e400001-b5a3-f393-e0a9-68716563686f`
|
||||
- Write Characteristic UUID: `6e400002-b5a3-f393-e0a9-68716563686f`
|
||||
- Notify Characteristic UUID: `6e400003-b5a3-f393-e0a9-68716563686f`
|
||||
|
||||
## 使用步骤
|
||||
|
||||
### 1. 连接蓝牙设备
|
||||
- 点击"连接蓝牙"按钮
|
||||
- 选择您的ECG设备
|
||||
- 等待连接成功
|
||||
|
||||
### 2. 发送指令
|
||||
连接成功后,点击"发送指令"按钮,会弹出指令选择对话框:
|
||||
|
||||
#### 指令类型选择:
|
||||
- **轻迅协议指令**:使用轻迅蓝牙通信协议的标准指令
|
||||
- **通用指令**:使用通用的字符串或十六进制指令
|
||||
- **自定义指令**:输入自定义指令
|
||||
|
||||
### 3. 轻迅协议指令
|
||||
选择"轻迅协议指令"后,会显示以下选项:
|
||||
|
||||
#### 采集控制:
|
||||
- **开启采集**:发送功能码 `0x0001` 开启数据采集
|
||||
- **停止采集**:发送功能码 `0x0001` 停止数据采集
|
||||
|
||||
#### 设备信息:
|
||||
- **查询设备信息**:发送功能码 `0x0000` 查询设备参数
|
||||
- **查询电量**:发送功能码 `0x0002` 查询设备电量
|
||||
|
||||
#### 滤波控制:
|
||||
- **工频滤波开关**:发送功能码 `0x000A` 控制工频滤波
|
||||
|
||||
### 4. 通用指令
|
||||
选择"通用指令"后,会显示以下选项:
|
||||
- **开始发送数据**:让设备开始发送数据流
|
||||
- **停止发送数据**:让设备停止发送数据
|
||||
- **开始ECG测量**:开始ECG信号测量
|
||||
- **停止ECG测量**:停止ECG信号测量
|
||||
|
||||
#### 常用指令格式:
|
||||
- **字符串指令**:`START_DATA`, `STOP`, `BEGIN`, `END`
|
||||
- **十六进制指令**:`0x01`, `0x02`, `0x53 0x54 0x41 0x52 0x54`
|
||||
|
||||
### 5. 查看实时图表
|
||||
发送指令成功后,应用会自动启动数据处理并立即开始绘制单导联ECG图表:
|
||||
- **自动启动**:发送指令后自动启动数据处理,无需手动点击"启动程序"
|
||||
- **实时显示**:单导联数据接收后立即更新图表,无需等待
|
||||
- **单导联处理**:专门处理单导联ECG数据,主要显示主导联信号
|
||||
- **ECG节律视图**:显示10秒的连续单导联信号
|
||||
- **ECG波形视图**:显示2.5秒的放大单导联信号
|
||||
|
||||
### 6. 其他功能
|
||||
- **陷波滤波**:开启/关闭50Hz陷波滤波器
|
||||
- **清空数据**:清空所有图表数据
|
||||
- **停止程序**:停止数据处理
|
||||
|
||||
## 轻迅协议指令详解
|
||||
|
||||
### 采集控制指令
|
||||
|
||||
#### 开启采集 (功能码: 0x0001)
|
||||
```
|
||||
数据包格式: [功能码(2字节)] [数据长度(2字节)] [采集开关(1字节)] [时间戳(8字节)] [CRC16(2字节)]
|
||||
示例: 01 00 09 00 01 [时间戳8字节] [CRC16 2字节]
|
||||
```
|
||||
|
||||
**时间戳选项:**
|
||||
- **立即开启**:时间戳设为0
|
||||
- **延迟开启**:时间戳设为未来时间(毫秒)
|
||||
- **指定时间戳**:自定义时间戳
|
||||
|
||||
#### 停止采集 (功能码: 0x0001)
|
||||
```
|
||||
数据包格式: [功能码(2字节)] [数据长度(2字节)] [采集开关(1字节)] [时间戳(8字节)] [CRC16(2字节)]
|
||||
示例: 01 00 09 00 00 [时间戳8字节] [CRC16 2字节]
|
||||
```
|
||||
|
||||
### 设备信息指令
|
||||
|
||||
#### 查询设备信息 (功能码: 0x0000)
|
||||
```
|
||||
数据包格式: [功能码(2字节)] [数据长度(2字节)] [CRC16(2字节)]
|
||||
示例: 00 00 00 00 [CRC16 2字节]
|
||||
```
|
||||
|
||||
#### 查询电量 (功能码: 0x0002)
|
||||
```
|
||||
数据包格式: [功能码(2字节)] [数据长度(2字节)] [CRC16(2字节)]
|
||||
示例: 02 00 00 00 [CRC16 2字节]
|
||||
```
|
||||
|
||||
### 滤波控制指令
|
||||
|
||||
#### 工频滤波开关 (功能码: 0x000A)
|
||||
```
|
||||
数据包格式: [功能码(2字节)] [数据长度(2字节)] [开关状态(1字节)] [CRC16(2字节)]
|
||||
开启示例: 0A 00 01 00 01 [CRC16 2字节]
|
||||
关闭示例: 0A 00 01 00 00 [CRC16 2字节]
|
||||
```
|
||||
|
||||
## 通用指令示例
|
||||
|
||||
### 开始数据流
|
||||
```
|
||||
START_DATA
|
||||
START
|
||||
0x01
|
||||
```
|
||||
|
||||
### 停止数据流
|
||||
```
|
||||
STOP_DATA
|
||||
STOP
|
||||
0x02
|
||||
```
|
||||
|
||||
### 开始ECG测量
|
||||
```
|
||||
START_ECG
|
||||
ECG_START
|
||||
0x03
|
||||
```
|
||||
|
||||
### 停止ECG测量
|
||||
```
|
||||
STOP_ECG
|
||||
ECG_STOP
|
||||
0x04
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
1. 确保蓝牙设备已正确连接
|
||||
2. 根据您的设备协议选择合适的指令类型
|
||||
3. 轻迅协议设备会自动计算CRC16校验,确保数据完整性
|
||||
4. 如果指令发送失败,请检查设备是否支持该指令
|
||||
5. 可以尝试不同的指令格式(字符串或十六进制)
|
||||
|
||||
## 调试信息
|
||||
应用会在状态栏显示详细的调试信息,包括:
|
||||
- 蓝牙连接状态
|
||||
- 指令发送结果(包括轻迅协议数据包内容)
|
||||
- 数据接收情况
|
||||
- 图表更新状态
|
||||
|
||||
## 轻迅协议特性
|
||||
- **自动CRC16校验**:所有指令包都包含CRC16-CCITT-FALSE校验
|
||||
- **时间戳同步**:支持指定时间戳进行同步采集
|
||||
- **工频滤波控制**:可动态开启/关闭工频滤波算法
|
||||
- **设备信息查询**:支持查询设备状态和电量信息
|
||||
- **自动数据处理**:发送指令后自动启动数据处理,无需手动操作
|
||||
- **实时图表更新**:单导联数据接收后立即更新图表,提供最佳用户体验
|
||||
- **单导联优化**:专门优化处理单导联ECG数据,不再处理12导联文件数据
|
||||
|
||||
## 测试功能
|
||||
- **点击"启动程序"按钮**:立即生成测试数据并显示图表,用于验证图表显示功能
|
||||
- **长按"启动程序"按钮**:生成更复杂的模拟ECG数据
|
||||
- **蓝牙数据测试**:当接收到蓝牙数据但原生解析器无法解析时,会自动生成测试数据
|
||||
|
||||
### 测试数据说明
|
||||
应用会生成模拟的单导联ECG波形,包含:
|
||||
- P波、QRS复合波、T波等典型ECG特征
|
||||
- 适当的噪声模拟真实信号
|
||||
- 500个数据点用于充分显示波形
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
# 信号处理JNI封装使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已将C++信号处理功能封装成JNI接口,可以在Android应用中直接调用。主要功能包括:
|
||||
|
||||
- 各种数字滤波器(带通、低通、高通、陷波)
|
||||
- 信号质量评估
|
||||
- ECG信号质量指数计算
|
||||
- 信号特征提取
|
||||
- 信号预处理(归一化、去直流等)
|
||||
- 实时信号处理
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
app/src/main/
|
||||
├── cpp/
|
||||
│ ├── include/cpp/
|
||||
│ │ └── signal_processor.h # C++头文件
|
||||
│ ├── src/
|
||||
│ │ └── signal_processor.cpp # C++实现文件(需要修复乱码)
|
||||
│ ├── jni/
|
||||
│ │ └── jni_bridge.cpp # 统一的JNI桥接文件(包含所有JNI函数)
|
||||
│ └── CMakeLists.txt # 构建配置
|
||||
└── java/
|
||||
└── com/example/cmake_project_test/
|
||||
├── SignalProcessorJNI.kt # Java JNI接口类
|
||||
└── SignalProcessorExample.kt # 使用示例类
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```kotlin
|
||||
// 创建信号处理器实例
|
||||
val signalProcessor = SignalProcessorJNI()
|
||||
if (signalProcessor.createProcessor()) {
|
||||
// 使用各种信号处理功能
|
||||
val filteredSignal = signalProcessor.bandpassFilter(
|
||||
inputSignal,
|
||||
sampleRate = 1000.0,
|
||||
lowFreq = 40.0,
|
||||
highFreq = 60.0
|
||||
)
|
||||
|
||||
// 清理资源
|
||||
signalProcessor.destroyProcessor()
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 主要功能
|
||||
|
||||
#### 数字滤波
|
||||
|
||||
```kotlin
|
||||
// 带通滤波
|
||||
val bandpassResult = signalProcessor.bandpassFilter(
|
||||
signal, sampleRate, lowFreq, highFreq
|
||||
)
|
||||
|
||||
// 低通滤波
|
||||
val lowpassResult = signalProcessor.lowpassFilter(
|
||||
signal, sampleRate, cutoffFreq
|
||||
)
|
||||
|
||||
// 高通滤波
|
||||
val highpassResult = signalProcessor.highpassFilter(
|
||||
signal, sampleRate, cutoffFreq
|
||||
)
|
||||
|
||||
// 陷波滤波(去除工频干扰)
|
||||
val notchResult = signalProcessor.notchFilter(
|
||||
signal, sampleRate, notchFreq, qFactor
|
||||
)
|
||||
```
|
||||
|
||||
#### 信号质量评估
|
||||
|
||||
```kotlin
|
||||
// 计算信号质量指数
|
||||
val quality = signalProcessor.calculateSignalQuality(signal)
|
||||
|
||||
// 计算ECG信号质量指数
|
||||
val ecgSQI = signalProcessor.calculateECGSQI(ecgSignal, sampleRate)
|
||||
|
||||
// 计算两个信号的相关性
|
||||
val correlation = signalProcessor.calculateCorrelation(signal1, signal2)
|
||||
```
|
||||
|
||||
#### 信号预处理
|
||||
|
||||
```kotlin
|
||||
// 归一化信号幅度
|
||||
signalProcessor.normalizeAmplitude(signal)
|
||||
|
||||
// 提取信号特征
|
||||
val features = signalProcessor.extractFeatures(signal, sampleRate)
|
||||
|
||||
// 重置滤波器状态
|
||||
signalProcessor.resetFilters()
|
||||
```
|
||||
|
||||
#### 实时处理
|
||||
|
||||
```kotlin
|
||||
// 实时处理数据块
|
||||
val processedChunk = signalProcessor.processRealtimeChunk(
|
||||
chunk, sampleRate
|
||||
)
|
||||
```
|
||||
|
||||
### 3. 完整示例
|
||||
|
||||
参考 `SignalProcessorExample.kt` 文件,其中包含了所有功能的演示代码:
|
||||
|
||||
```kotlin
|
||||
val example = SignalProcessorExample()
|
||||
|
||||
// 运行所有演示
|
||||
example.runAllDemonstrations()
|
||||
|
||||
// 清理资源
|
||||
example.cleanup()
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 输入数据
|
||||
|
||||
- 所有信号数据使用 `FloatArray` 格式
|
||||
- 采样率使用 `Double` 类型
|
||||
- 频率参数使用 `Double` 类型
|
||||
|
||||
### 输出数据
|
||||
|
||||
- 滤波结果返回 `FloatArray?`(可能为null表示处理失败)
|
||||
- 质量指数返回 `Float` 类型(0.0-1.0)
|
||||
- 相关性返回 `Float` 类型(-1.0到1.0)
|
||||
|
||||
### 内部转换
|
||||
|
||||
- Java端使用 `ByteBuffer` 进行 `FloatArray` 和 `ByteArray` 的转换
|
||||
- C++端使用 `std::vector<float>` 处理数据
|
||||
- 字节序使用小端序(Little Endian)
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 资源管理
|
||||
|
||||
```kotlin
|
||||
// 必须在使用前创建处理器
|
||||
if (!signalProcessor.createProcessor()) {
|
||||
// 处理创建失败的情况
|
||||
return
|
||||
}
|
||||
|
||||
// 使用完毕后必须销毁处理器
|
||||
signalProcessor.destroyProcessor()
|
||||
```
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
```kotlin
|
||||
val result = signalProcessor.bandpassFilter(signal, sampleRate, lowFreq, highFreq)
|
||||
if (result != null) {
|
||||
// 处理成功
|
||||
processResult(result)
|
||||
} else {
|
||||
// 处理失败
|
||||
Log.e("SignalProcessor", "滤波处理失败")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 性能考虑
|
||||
|
||||
- 滤波器初始化有一定开销,建议复用处理器实例
|
||||
- 大数据量处理时考虑分块处理
|
||||
- 实时处理时注意内存分配
|
||||
|
||||
### 4. 线程安全
|
||||
|
||||
- JNI函数不是线程安全的
|
||||
- 多线程使用时需要适当的同步机制
|
||||
- 建议在主线程或专用工作线程中使用
|
||||
|
||||
## 构建配置
|
||||
|
||||
### CMakeLists.txt
|
||||
|
||||
确保在 `CMakeLists.txt` 中包含了信号处理库:
|
||||
|
||||
```cmake
|
||||
# Signal processor static library
|
||||
add_library(signal_processor STATIC
|
||||
src/signal_processor.cpp)
|
||||
|
||||
target_include_directories(signal_processor PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||
|
||||
# 链接到主库
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
core_math
|
||||
data_parser
|
||||
signal_processor
|
||||
android
|
||||
log)
|
||||
```
|
||||
|
||||
**注意**:信号处理的JNI函数现在已整合到 `jni_bridge.cpp` 中,不再需要单独的 `signal_processor_jni.cpp` 文件。
|
||||
|
||||
### 库加载
|
||||
|
||||
在Java代码中确保正确加载原生库:
|
||||
|
||||
```kotlin
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("cmake_project_test")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 编译错误
|
||||
|
||||
- 检查 `CMakeLists.txt` 配置
|
||||
- 确保所有源文件路径正确
|
||||
- 检查头文件包含路径
|
||||
|
||||
### 2. 运行时错误
|
||||
|
||||
- 检查Logcat中的错误信息
|
||||
- 确保处理器已正确创建
|
||||
- 检查输入数据格式和大小
|
||||
|
||||
### 3. 性能问题
|
||||
|
||||
- 使用Android Profiler分析性能瓶颈
|
||||
- 考虑减少数据转换开销
|
||||
- 优化滤波器参数
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 1. 添加新的滤波器
|
||||
|
||||
1. 在 `signal_processor.h` 中声明新方法
|
||||
2. 在 `signal_processor.cpp` 中实现
|
||||
3. 在 `signal_processor_jni.cpp` 中添加JNI封装
|
||||
4. 在 `SignalProcessorJNI.kt` 中添加Java接口
|
||||
|
||||
### 2. 自定义信号处理
|
||||
|
||||
可以基于现有的JNI接口构建更高级的信号处理功能:
|
||||
|
||||
```kotlin
|
||||
class AdvancedSignalProcessor(private val jni: SignalProcessorJNI) {
|
||||
fun adaptiveFilter(signal: FloatArray, sampleRate: Double): FloatArray? {
|
||||
// 实现自适应滤波算法
|
||||
// 使用JNI接口调用底层功能
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过JNI封装,您现在可以在Android应用中直接使用C++的高性能信号处理功能。这种架构既保持了C++的性能优势,又提供了Java/Kotlin的易用性。
|
||||
|
||||
建议在使用前先运行示例代码,熟悉各种功能的使用方法,然后根据具体需求进行定制化开发。
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
# 智能UUID检测指南
|
||||
|
||||
## 🎯 问题分析
|
||||
|
||||
从你提供的日志可以看出:
|
||||
```
|
||||
发现特征: 00002a29-0000-1000-8000-00805f9b34fb
|
||||
发现特征: 00002a24-0000-1000-8000-00805f9b34fb
|
||||
发现特征: 00002a25-0000-1000-8000-00805f9b34fb
|
||||
```
|
||||
|
||||
这些都是**标准蓝牙GATT服务**的特征UUID:
|
||||
- `00002a29` = Manufacturer Name String
|
||||
- `00002a24` = Model Number String
|
||||
- `00002a25` = Serial Number String
|
||||
- `00002a27` = Hardware Revision String
|
||||
- `00002a26` = Firmware Revision String
|
||||
- `00002a28` = Software Revision String
|
||||
|
||||
## 🔧 解决方案
|
||||
|
||||
### 1. 扩展UUID列表 ✅
|
||||
- **新增**:14个常见ECG设备UUID变体
|
||||
- **覆盖**:更多厂商的设备协议
|
||||
- **范围**:`0000ff00` 到 `0000ffb0`
|
||||
|
||||
### 2. 智能UUID检测 ✅
|
||||
- **实现**:自动检测非标准蓝牙服务
|
||||
- **逻辑**:跳过标准服务(`00002a`、`000018`)
|
||||
- **目标**:查找自定义服务和特征
|
||||
|
||||
### 3. 通知特征检测 ✅
|
||||
- **实现**:检查特征是否支持通知
|
||||
- **条件**:`PROPERTY_NOTIFY` 或 `PROPERTY_INDICATE`
|
||||
- **目的**:确保能接收数据
|
||||
|
||||
## 📱 检测流程
|
||||
|
||||
### 第一步:预设UUID匹配
|
||||
```
|
||||
尝试匹配预设的ECG设备UUID:
|
||||
- 0000fff0-0000-1000-8000-00805f9b34fb
|
||||
- 0000ffe0-0000-1000-8000-00805f9b34fb
|
||||
- 0000ffe5-0000-1000-8000-00805f9b34fb
|
||||
- ... (共15个变体)
|
||||
```
|
||||
|
||||
### 第二步:智能检测
|
||||
```
|
||||
如果预设UUID未找到匹配:
|
||||
1. 扫描所有服务
|
||||
2. 跳过标准蓝牙服务(00002a, 000018)
|
||||
3. 查找自定义服务
|
||||
4. 检查特征是否支持通知
|
||||
5. 自动选择第一个匹配的特征
|
||||
```
|
||||
|
||||
### 第三步:建立连接
|
||||
```
|
||||
找到匹配的服务和特征后:
|
||||
1. 启用特征通知
|
||||
2. 开始接收数据
|
||||
3. 显示"数据通道已建立"
|
||||
```
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:重新连接设备
|
||||
1. **断开当前连接**
|
||||
2. **重新点击"连接蓝牙"**
|
||||
3. **选择你的设备**
|
||||
|
||||
### 第二步:观察调试信息
|
||||
1. **查看服务发现过程**:
|
||||
```
|
||||
蓝牙状态: 服务发现成功
|
||||
设备提供的服务数量: X
|
||||
发现服务: [UUID列表]
|
||||
```
|
||||
|
||||
2. **查看智能检测过程**:
|
||||
```
|
||||
预设UUID未找到匹配,尝试智能检测
|
||||
发现自定义服务: [UUID]
|
||||
发现自定义特征: [UUID]
|
||||
找到支持通知的特征: [UUID]
|
||||
```
|
||||
|
||||
3. **查看连接结果**:
|
||||
```
|
||||
数据通道已建立,开始接收数据
|
||||
```
|
||||
|
||||
### 第三步:验证数据接收
|
||||
1. **检查状态信息**:
|
||||
- ✅ "数据通道已建立,开始接收数据"
|
||||
- ✅ "接收到数据: X 字节"
|
||||
|
||||
2. **检查ECG图表**:
|
||||
- ✅ 图表变为可见
|
||||
- ✅ 开始显示实时数据
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### 1. 设备使用标准服务
|
||||
**现象**:只显示`00002a`开头的UUID
|
||||
**解决**:智能检测会自动跳过这些服务
|
||||
|
||||
### 2. 设备需要特定触发
|
||||
**现象**:连接成功但没有数据
|
||||
**解决**:可能需要发送特定命令激活数据流
|
||||
|
||||
### 3. 设备使用非标准UUID
|
||||
**现象**:UUID不在预设列表中
|
||||
**解决**:智能检测会自动发现自定义UUID
|
||||
|
||||
## 📋 设备信息收集
|
||||
|
||||
请提供以下信息:
|
||||
|
||||
### 1. 设备基本信息
|
||||
- **设备名称**:你的ECG设备显示的名称
|
||||
- **设备型号**:如果知道的话
|
||||
- **厂商信息**:设备制造商
|
||||
|
||||
### 2. 服务UUID信息
|
||||
```
|
||||
请提供完整的服务发现日志:
|
||||
- 所有发现的服务UUID
|
||||
- 所有发现的特征UUID
|
||||
- 智能检测的结果
|
||||
```
|
||||
|
||||
### 3. 连接状态
|
||||
- **是否成功建立数据通道?**
|
||||
- **是否开始接收数据?**
|
||||
- **数据大小是多少字节?**
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 成功情况:
|
||||
- ✅ 显示"数据通道已建立,开始接收数据"
|
||||
- ✅ 开始接收ECG数据
|
||||
- ✅ ECG图表显示实时波形
|
||||
|
||||
### 需要进一步调试:
|
||||
- ❌ 仍然显示"未找到匹配的服务或特征"
|
||||
- ℹ️ 需要查看完整的服务发现日志
|
||||
- 🔧 可能需要手动配置特定UUID
|
||||
|
||||
现在请重新连接你的设备,观察新的调试信息!🎯
|
||||
295
TESTING_GUIDE.md
295
TESTING_GUIDE.md
|
|
@ -1,295 +0,0 @@
|
|||
# 完整功能测试指南
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证应用的完整功能,包括:
|
||||
- ✅ 蓝牙权限申请
|
||||
- ✅ 蓝牙设备扫描和连接
|
||||
- ✅ 实时数据接收和处理
|
||||
- ✅ ECG曲线图显示
|
||||
- ✅ 信号处理和指标计算
|
||||
|
||||
## 📱 测试环境准备
|
||||
|
||||
### 1. 设备要求
|
||||
- **Android设备**:Android 6.0+(推荐Android 12+)
|
||||
- **蓝牙功能**:支持BLE或传统蓝牙
|
||||
- **位置权限**:需要位置权限用于蓝牙扫描
|
||||
|
||||
### 2. 心电设备
|
||||
- **真实设备**:支持BLE或传统蓝牙的心电设备
|
||||
- **模拟设备**:可以使用蓝牙调试工具模拟数据
|
||||
|
||||
### 3. 开发环境
|
||||
- **Android Studio**:最新版本
|
||||
- **ADB工具**:用于日志查看和调试
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:应用安装和启动
|
||||
|
||||
1. **编译应用**
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
2. **安装到设备**
|
||||
```bash
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
3. **启动应用**
|
||||
- 在设备上找到并启动应用
|
||||
- 观察启动界面和权限状态显示
|
||||
|
||||
### 第二步:权限申请测试
|
||||
|
||||
1. **查看权限状态**
|
||||
```
|
||||
权限状态:
|
||||
BLUETOOTH_SCAN: ✗
|
||||
BLUETOOTH_CONNECT: ✗
|
||||
ACCESS_FINE_LOCATION: ✗
|
||||
ACCESS_COARSE_LOCATION: ✗
|
||||
```
|
||||
|
||||
2. **点击"连接蓝牙"按钮**
|
||||
- 系统应弹出权限申请对话框
|
||||
- 选择"允许"授予所有权限
|
||||
|
||||
3. **验证权限状态**
|
||||
```
|
||||
权限状态:
|
||||
BLUETOOTH_SCAN: ✓
|
||||
BLUETOOTH_CONNECT: ✓
|
||||
ACCESS_FINE_LOCATION: ✓
|
||||
ACCESS_COARSE_LOCATION: ✓
|
||||
```
|
||||
|
||||
### 第三步:蓝牙扫描测试
|
||||
|
||||
1. **开始扫描**
|
||||
- 点击"连接蓝牙"按钮
|
||||
- 按钮文字应变为"扫描中..."
|
||||
- 按钮颜色应变为橙色
|
||||
|
||||
2. **观察扫描过程**
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: ECG_Device_001 (00:11:22:33:44:55)
|
||||
扫描完成,找到 1 个设备
|
||||
```
|
||||
|
||||
3. **设备选择对话框**
|
||||
- 扫描完成后应弹出设备选择对话框
|
||||
- 显示发现的设备列表
|
||||
|
||||
### 第四步:设备连接测试
|
||||
|
||||
1. **选择设备**
|
||||
- 在设备列表中选择目标设备
|
||||
- 点击设备名称进行连接
|
||||
|
||||
2. **连接过程**
|
||||
```
|
||||
正在连接设备: ECG_Device_001
|
||||
设备已连接
|
||||
服务发现成功
|
||||
数据通道已建立
|
||||
```
|
||||
|
||||
3. **连接状态验证**
|
||||
- 按钮文字应变为"断开蓝牙"
|
||||
- 按钮颜色应变为红色
|
||||
|
||||
### 第五步:数据接收测试
|
||||
|
||||
1. **启动数据采集**
|
||||
- 点击"启动程序"按钮
|
||||
- 观察ECG曲线图开始显示数据
|
||||
|
||||
2. **数据接收验证**
|
||||
```
|
||||
接收到蓝牙数据: 128 字节
|
||||
接收到蓝牙数据: 256 字节
|
||||
接收到蓝牙数据: 512 字节
|
||||
```
|
||||
|
||||
3. **实时显示验证**
|
||||
- ECG节律视图(10秒)应显示实时曲线
|
||||
- ECG波形视图(2.5秒)应显示详细波形
|
||||
- 曲线应平滑滚动,无卡顿
|
||||
|
||||
### 第六步:信号处理测试
|
||||
|
||||
1. **滤波功能**
|
||||
- 点击"陷波滤波"按钮切换滤波状态
|
||||
- 观察曲线图的变化
|
||||
|
||||
2. **指标计算**
|
||||
- 查看状态信息中的心率等指标
|
||||
- 验证指标计算的准确性
|
||||
|
||||
3. **性能测试**
|
||||
- 观察应用的响应速度
|
||||
- 检查内存使用情况
|
||||
|
||||
## 🔍 调试技巧
|
||||
|
||||
### 1. 日志查看
|
||||
```bash
|
||||
# 查看蓝牙相关日志
|
||||
adb logcat | grep BluetoothManager
|
||||
|
||||
# 查看权限相关日志
|
||||
adb logcat | grep MainActivity
|
||||
|
||||
# 查看数据处理日志
|
||||
adb logcat | grep DataManager
|
||||
|
||||
# 查看信号处理日志
|
||||
adb logcat | grep StreamingSignalProcessor
|
||||
```
|
||||
|
||||
### 2. 常见日志模式
|
||||
```
|
||||
BluetoothManager: 蓝牙初始化成功
|
||||
BluetoothManager: BLE扫描已开始
|
||||
BluetoothManager: 发现BLE设备: ECG_Device_001
|
||||
BluetoothManager: 设备已连接
|
||||
BluetoothManager: 接收到数据: 128 字节
|
||||
```
|
||||
|
||||
### 3. 错误诊断
|
||||
```bash
|
||||
# 查看错误日志
|
||||
adb logcat | grep -i error
|
||||
|
||||
# 查看警告日志
|
||||
adb logcat | grep -i warning
|
||||
```
|
||||
|
||||
## ⚠️ 常见问题排查
|
||||
|
||||
### 1. 权限问题
|
||||
**症状**:点击按钮无反应
|
||||
**排查**:
|
||||
```bash
|
||||
# 检查权限状态
|
||||
adb shell dumpsys package com.example.cmake_project_test | grep permission
|
||||
```
|
||||
|
||||
**解决**:
|
||||
- 进入系统设置手动授予权限
|
||||
- 重启应用
|
||||
|
||||
### 2. 蓝牙扫描问题
|
||||
**症状**:扫描无结果
|
||||
**排查**:
|
||||
```bash
|
||||
# 检查蓝牙状态
|
||||
adb shell dumpsys bluetooth
|
||||
```
|
||||
|
||||
**解决**:
|
||||
- 确认设备蓝牙已开启
|
||||
- 确认设备处于可发现状态
|
||||
- 检查设备距离
|
||||
|
||||
### 3. 连接问题
|
||||
**症状**:连接失败
|
||||
**排查**:
|
||||
```bash
|
||||
# 查看连接日志
|
||||
adb logcat | grep -i "connection\|connect"
|
||||
```
|
||||
|
||||
**解决**:
|
||||
- 确认设备未被其他应用占用
|
||||
- 检查设备电池电量
|
||||
- 重启设备和应用
|
||||
|
||||
### 4. 数据显示问题
|
||||
**症状**:曲线图无数据
|
||||
**排查**:
|
||||
```bash
|
||||
# 查看数据处理日志
|
||||
adb logcat | grep -i "data\|process"
|
||||
```
|
||||
|
||||
**解决**:
|
||||
- 检查数据格式是否正确
|
||||
- 确认数据处理管道正常工作
|
||||
- 检查UI更新逻辑
|
||||
|
||||
## 📊 性能指标
|
||||
|
||||
### 1. 响应时间
|
||||
- **权限申请**:< 1秒
|
||||
- **蓝牙扫描**:< 10秒
|
||||
- **设备连接**:< 5秒
|
||||
- **数据接收**:实时
|
||||
|
||||
### 2. 内存使用
|
||||
- **应用启动**:< 100MB
|
||||
- **数据采集**:< 200MB
|
||||
- **长时间运行**:< 300MB
|
||||
|
||||
### 3. 电池消耗
|
||||
- **待机状态**:< 5%/小时
|
||||
- **数据采集**:< 15%/小时
|
||||
|
||||
## 🎉 测试完成标准
|
||||
|
||||
### 功能完整性
|
||||
- ✅ 权限申请正常工作
|
||||
- ✅ 蓝牙扫描发现设备
|
||||
- ✅ 设备连接成功
|
||||
- ✅ 数据接收正常
|
||||
- ✅ 实时显示流畅
|
||||
- ✅ 信号处理有效
|
||||
- ✅ 指标计算准确
|
||||
|
||||
### 用户体验
|
||||
- ✅ 界面响应及时
|
||||
- ✅ 状态提示清晰
|
||||
- ✅ 错误处理友好
|
||||
- ✅ 操作流程顺畅
|
||||
|
||||
### 稳定性
|
||||
- ✅ 长时间运行稳定
|
||||
- ✅ 内存使用合理
|
||||
- ✅ 电池消耗正常
|
||||
- ✅ 无崩溃或卡死
|
||||
|
||||
## 📝 测试报告模板
|
||||
|
||||
```
|
||||
测试日期:YYYY-MM-DD
|
||||
测试设备:设备型号 + Android版本
|
||||
测试人员:姓名
|
||||
|
||||
功能测试结果:
|
||||
□ 权限申请:通过/失败
|
||||
□ 蓝牙扫描:通过/失败
|
||||
□ 设备连接:通过/失败
|
||||
□ 数据接收:通过/失败
|
||||
□ 实时显示:通过/失败
|
||||
□ 信号处理:通过/失败
|
||||
□ 指标计算:通过/失败
|
||||
|
||||
性能测试结果:
|
||||
□ 响应时间:正常/异常
|
||||
□ 内存使用:正常/异常
|
||||
□ 电池消耗:正常/异常
|
||||
|
||||
问题记录:
|
||||
1. 问题描述
|
||||
2. 复现步骤
|
||||
3. 解决方案
|
||||
|
||||
总体评价:
|
||||
□ 优秀 □ 良好 □ 一般 □ 需要改进
|
||||
```
|
||||
|
||||
现在你的应用已经具备了完整的功能,可以进行全面的测试了!🎉
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# UI修复测试指南
|
||||
|
||||
## 🎯 修复内容
|
||||
|
||||
### 1. 图表隐藏功能 ✅
|
||||
- **问题**:ECG图表在启动时就显示,占用屏幕空间
|
||||
- **解决**:图表默认隐藏,只有在接收到数据时才显示
|
||||
- **实现**:
|
||||
```xml
|
||||
android:visibility="gone" <!-- 默认隐藏 -->
|
||||
```
|
||||
|
||||
### 2. 设备选择对话框 ✅
|
||||
- **问题**:扫描完成后没有显示设备选择对话框
|
||||
- **解决**:确保`onScanComplete`回调正确显示对话框
|
||||
- **实现**:
|
||||
```kotlin
|
||||
override fun onScanComplete(devices: List<BluetoothDevice>) {
|
||||
if (devices.isNotEmpty()) {
|
||||
val dialog = BluetoothDeviceDialog.newInstance(devices)
|
||||
dialog.show(supportFragmentManager, "BluetoothDeviceDialog")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 现在的UI布局
|
||||
|
||||
### 启动时的界面
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [连接蓝牙] [启动程序] │
|
||||
│ [停止程序] [陷波滤波] │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 状态信息区域 │
|
||||
│ [应用已就绪,可以开始使用] │
|
||||
│ [权限状态信息] │
|
||||
│ [点击"连接蓝牙"按钮开始蓝牙连接...] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 有数据时的界面
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [连接蓝牙] [启动程序] │
|
||||
│ [停止程序] [陷波滤波] │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ECG实时监测 │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [实时ECG曲线图] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [详细ECG波形图] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 状态信息区域 │
|
||||
│ [蓝牙状态信息] │
|
||||
│ [数据处理状态] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 测试步骤
|
||||
|
||||
### 第一步:验证启动界面
|
||||
1. **启动应用**
|
||||
2. **确认界面**:
|
||||
- ✅ 只显示按钮区域和状态信息区域
|
||||
- ✅ ECG图表区域完全隐藏
|
||||
- ✅ 按钮没有超出屏幕边界
|
||||
|
||||
### 第二步:测试蓝牙扫描
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **观察扫描过程**:
|
||||
```
|
||||
蓝牙状态: 正在扫描蓝牙设备...
|
||||
发现设备: [设备名称] ([地址])
|
||||
发现设备: [设备名称] ([地址])
|
||||
```
|
||||
|
||||
### 第三步:验证设备选择对话框
|
||||
1. **扫描完成后**:
|
||||
- ✅ 应该弹出设备选择对话框
|
||||
- ✅ 显示所有发现的设备列表
|
||||
- ✅ 每个设备显示名称和地址
|
||||
|
||||
### 第四步:测试设备连接
|
||||
1. **选择任意设备**:
|
||||
- 点击设备名称
|
||||
- 观察连接过程
|
||||
2. **验证连接状态**:
|
||||
- 按钮文字变为"断开蓝牙"
|
||||
- 按钮颜色变为红色
|
||||
|
||||
### 第五步:测试数据显示
|
||||
1. **连接成功后点击"启动程序"**
|
||||
2. **观察图表显示**:
|
||||
- ✅ ECG图表区域应该出现
|
||||
- ✅ 显示实时数据曲线
|
||||
- ✅ 界面布局合理,不超出屏幕
|
||||
|
||||
## 🔧 关键代码修改
|
||||
|
||||
### 1. 布局文件修改
|
||||
```xml
|
||||
<!-- ECG图表区域 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/ecg_chart_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="0.7"
|
||||
android:orientation="vertical"
|
||||
android:background="#F8F8F8"
|
||||
android:visibility="gone"> <!-- 默认隐藏 -->
|
||||
```
|
||||
|
||||
### 2. 数据显示逻辑
|
||||
```kotlin
|
||||
override fun onDataReceived(data: ByteArray) {
|
||||
runOnUiThread {
|
||||
updateStatus("接收到蓝牙数据: ${data.size} 字节")
|
||||
dataManager.onBleNotify(data)
|
||||
|
||||
// 显示ECG图表
|
||||
binding.ecgChartContainer.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProcessedDataAvailable(channelIndex: Int, processedData: List<Float>) {
|
||||
if (channelIndex == 0) {
|
||||
try {
|
||||
// 显示ECG图表
|
||||
binding.ecgChartContainer.visibility = View.VISIBLE
|
||||
|
||||
binding.ecgRhythmView.updateData(processedData)
|
||||
binding.ecgWaveformView.updateData(processedData)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "处理实时数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 设备选择对话框
|
||||
- 确保`BluetoothDeviceDialog.kt`文件存在
|
||||
- 确保对话框正确显示设备列表
|
||||
- 确保点击设备后能正确连接
|
||||
|
||||
### 2. 图表显示时机
|
||||
- 图表只在有数据时才显示
|
||||
- 蓝牙数据接收时显示
|
||||
- 处理后的数据更新时显示
|
||||
|
||||
### 3. 界面适配
|
||||
- 按钮布局适应不同屏幕尺寸
|
||||
- 图表区域合理分配空间
|
||||
- 状态信息区域保持可见
|
||||
|
||||
## 🎉 预期效果
|
||||
|
||||
### 启动时:
|
||||
- ✅ 界面简洁,只显示必要元素
|
||||
- ✅ 按钮布局合理,不超出屏幕
|
||||
- ✅ 状态信息清晰可见
|
||||
|
||||
### 扫描时:
|
||||
- ✅ 显示扫描进度和设备发现信息
|
||||
- ✅ 扫描完成后弹出设备选择对话框
|
||||
|
||||
### 连接后:
|
||||
- ✅ 显示连接状态
|
||||
- ✅ 可以启动数据处理
|
||||
|
||||
### 有数据时:
|
||||
- ✅ ECG图表自动显示
|
||||
- ✅ 实时更新数据曲线
|
||||
- ✅ 界面布局完整合理
|
||||
|
||||
现在可以测试修复后的UI了!🎉
|
||||
|
|
@ -12,7 +12,7 @@ android {
|
|||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
externalNativeBuild {
|
||||
|
|
@ -22,6 +22,15 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("keystore.jks")
|
||||
storePassword = "android123"
|
||||
keyAlias = "key0"
|
||||
keyPassword = "android123"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
|
@ -29,6 +38,10 @@ android {
|
|||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -15,6 +15,10 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- 文件导出权限 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<!-- 蓝牙功能声明 -->
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
|
@ -27,16 +31,39 @@
|
|||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cmake_project_test">
|
||||
android:theme="@style/Theme.Cmake_project_test"
|
||||
android:description="@string/app_description"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".FullscreenChartActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Cmake_project_test.NoActionBar"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
|
||||
<!-- FileProvider for sharing files -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -172,6 +172,14 @@ private:
|
|||
const std::vector<std::vector<float>>& channels);
|
||||
std::vector<std::vector<float>> apply_stethoscope_filters(
|
||||
const std::vector<std::vector<float>>& channels);
|
||||
|
||||
// 新增:单导联心电专用处理函数
|
||||
std::vector<std::vector<float>> apply_single_lead_ecg_filters(
|
||||
const std::vector<std::vector<float>>& channels);
|
||||
std::vector<float> enhance_signal_quality(
|
||||
const std::vector<float>& signal, double sample_rate);
|
||||
std::vector<float> apply_smoothing(
|
||||
const std::vector<float>& signal, int window_size);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -241,13 +241,14 @@ SensorData parse_pw_ecg_sl(const uint8_t* data) {
|
|||
payload += 1;
|
||||
|
||||
// 解析单通道ECG数据 (115个采样点)
|
||||
auto& channel = result.channel_data.emplace<std::vector<float>>();
|
||||
channel.reserve(115);
|
||||
auto& channels = result.channel_data.emplace<std::vector<std::vector<float>>>();
|
||||
channels.resize(1); // 单通道
|
||||
channels[0].reserve(115);
|
||||
|
||||
for (int i = 0; i < 115; ++i) {
|
||||
int16_t adc_value = read_le<int16_t>(payload);
|
||||
payload += 2;
|
||||
channel.push_back(adc_value * 0.318f); // 转换为μV,与12导联心电保持一致
|
||||
channels[0].push_back(adc_value * 0.288f); // 转换为μV,与12导联心电保持一致
|
||||
}
|
||||
|
||||
// 跳过预留1字节
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ std::vector<std::vector<float>> SignalProcessor::apply_channel_filters(
|
|||
case DataType::ECG_12LEAD:
|
||||
filtered_channels = apply_ecg_filters(channels);
|
||||
break;
|
||||
case DataType::PW_ECG_SL:
|
||||
filtered_channels = apply_single_lead_ecg_filters(channels);
|
||||
break;
|
||||
case DataType::PPG:
|
||||
filtered_channels = apply_ppg_filters(channels);
|
||||
break;
|
||||
|
|
@ -1571,3 +1574,117 @@ FeatureSet SignalProcessor::extract_stethoscope_features(const SensorData& data)
|
|||
return result;
|
||||
}
|
||||
|
||||
// 新增:单导联心电专用信号处理函数
|
||||
std::vector<std::vector<float>> SignalProcessor::apply_single_lead_ecg_filters(
|
||||
const std::vector<std::vector<float>>& channels) {
|
||||
|
||||
std::vector<std::vector<float>> filtered_channels = channels;
|
||||
|
||||
for (auto& channel : filtered_channels) {
|
||||
if (channel.empty()) continue;
|
||||
|
||||
try {
|
||||
// 1. 去除直流分量
|
||||
channel = remove_dc_offset(channel);
|
||||
|
||||
// 2. 0.5Hz高通滤波(去除基线漂移)
|
||||
channel = filter(channel, 250.0, 0, 0.5, filtertype::highpass);
|
||||
|
||||
// 3. 50Hz陷波滤波(去除工频干扰)
|
||||
channel = filter(channel, 250.0, 49.5, 50.5, filtertype::notchpass);
|
||||
|
||||
// 4. 运动伪迹检测和去除
|
||||
channel = remove_motion_artifacts(channel, 250.0);
|
||||
|
||||
// 5. 信号质量评估和增强
|
||||
channel = enhance_signal_quality(channel, 250.0);
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "单导联心电滤波处理失败: " << e.what() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered_channels;
|
||||
}
|
||||
|
||||
// 新增:信号质量增强函数
|
||||
std::vector<float> SignalProcessor::enhance_signal_quality(
|
||||
const std::vector<float>& signal, double sample_rate) {
|
||||
|
||||
if (signal.empty()) return signal;
|
||||
|
||||
std::vector<float> enhanced_signal = signal;
|
||||
|
||||
try {
|
||||
// 1. 计算信号质量指标
|
||||
double mean_val = 0.0;
|
||||
double variance = 0.0;
|
||||
|
||||
for (float val : signal) {
|
||||
mean_val += val;
|
||||
}
|
||||
mean_val /= signal.size();
|
||||
|
||||
for (float val : signal) {
|
||||
double diff = val - mean_val;
|
||||
variance += diff * diff;
|
||||
}
|
||||
variance /= signal.size();
|
||||
|
||||
double std_dev = std::sqrt(variance);
|
||||
|
||||
// 2. 异常值检测和修正
|
||||
const double threshold = 3.0 * std_dev; // 3σ原则
|
||||
for (size_t i = 0; i < enhanced_signal.size(); ++i) {
|
||||
if (std::abs(enhanced_signal[i] - mean_val) > threshold) {
|
||||
// 使用前后点的平均值替换异常值
|
||||
if (i > 0 && i < enhanced_signal.size() - 1) {
|
||||
enhanced_signal[i] = (enhanced_signal[i-1] + enhanced_signal[i+1]) / 2.0f;
|
||||
} else if (i > 0) {
|
||||
enhanced_signal[i] = enhanced_signal[i-1];
|
||||
} else if (i < enhanced_signal.size() - 1) {
|
||||
enhanced_signal[i] = enhanced_signal[i+1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 平滑处理(轻微)
|
||||
enhanced_signal = apply_smoothing(enhanced_signal, 3);
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "信号质量增强失败: " << e.what() << std::endl;
|
||||
}
|
||||
|
||||
return enhanced_signal;
|
||||
}
|
||||
|
||||
// 新增:平滑处理函数
|
||||
std::vector<float> SignalProcessor::apply_smoothing(
|
||||
const std::vector<float>& signal, int window_size) {
|
||||
|
||||
if (signal.empty() || window_size < 3) return signal;
|
||||
|
||||
std::vector<float> smoothed_signal = signal;
|
||||
int half_window = window_size / 2;
|
||||
|
||||
for (size_t i = 0; i < signal.size(); ++i) {
|
||||
double sum = 0.0;
|
||||
int count = 0;
|
||||
|
||||
// 计算窗口内的平均值
|
||||
for (int j = -half_window; j <= half_window; ++j) {
|
||||
int idx = static_cast<int>(i) + j;
|
||||
if (idx >= 0 && idx < static_cast<int>(signal.size())) {
|
||||
sum += signal[idx];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
smoothed_signal[i] = static_cast<float>(sum / count);
|
||||
}
|
||||
}
|
||||
|
||||
return smoothed_signal;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import java.util.UUID
|
|||
*/
|
||||
class BluetoothManager(private val context: Context) {
|
||||
|
||||
// PPTM命令编码器
|
||||
private val pptmEncoder = PPTMCommandEncoder()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothManager"
|
||||
|
||||
|
|
@ -110,61 +113,15 @@ class BluetoothManager(private val context: Context) {
|
|||
// 常见的ECG设备UUID列表
|
||||
private val SERVICE_UUIDS = listOf(
|
||||
UUID.fromString("6e400001-b5a3-f393-e0a9-68716563686f"), // Nordic UART Service (NUS) - 你的设备
|
||||
UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"), // 默认
|
||||
UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"), // 常见变体1
|
||||
UUID.fromString("0000ffe5-0000-1000-8000-00805f9b34fb"), // 常见变体2
|
||||
UUID.fromString("0000ff00-0000-1000-8000-00805f9b34fb"), // 常见变体3
|
||||
UUID.fromString("0000ff10-0000-1000-8000-00805f9b34fb"), // 常见变体4
|
||||
UUID.fromString("0000ff20-0000-1000-8000-00805f9b34fb"), // 常见变体5
|
||||
UUID.fromString("0000ff30-0000-1000-8000-00805f9b34fb"), // 常见变体6
|
||||
UUID.fromString("0000ff40-0000-1000-8000-00805f9b34fb"), // 常见变体7
|
||||
UUID.fromString("0000ff50-0000-1000-8000-00805f9b34fb"), // 常见变体8
|
||||
UUID.fromString("0000ff60-0000-1000-8000-00805f9b34fb"), // 常见变体9
|
||||
UUID.fromString("0000ff70-0000-1000-8000-00805f9b34fb"), // 常见变体10
|
||||
UUID.fromString("0000ff80-0000-1000-8000-00805f9b34fb"), // 常见变体11
|
||||
UUID.fromString("0000ff90-0000-1000-8000-00805f9b34fb"), // 常见变体12
|
||||
UUID.fromString("0000ffa0-0000-1000-8000-00805f9b34fb"), // 常见变体13
|
||||
UUID.fromString("0000ffb0-0000-1000-8000-00805f9b34fb") // 常见变体14
|
||||
)
|
||||
|
||||
private val CHARACTERISTIC_UUIDS = listOf(
|
||||
UUID.fromString("6e400002-b5a3-f393-e0a9-68716563686f"), // Nordic UART TX Characteristic - 发送数据
|
||||
UUID.fromString("6e400003-b5a3-f393-e0a9-68716563686f"), // Nordic UART RX Characteristic - 接收数据(Notify)
|
||||
UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"), // 默认
|
||||
UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"), // 常见变体1
|
||||
UUID.fromString("0000ffe6-0000-1000-8000-00805f9b34fb"), // 常见变体2
|
||||
UUID.fromString("0000ff01-0000-1000-8000-00805f9b34fb"), // 常见变体3
|
||||
UUID.fromString("0000ff11-0000-1000-8000-00805f9b34fb"), // 常见变体4
|
||||
UUID.fromString("0000ff21-0000-1000-8000-00805f9b34fb"), // 常见变体5
|
||||
UUID.fromString("0000ff31-0000-1000-8000-00805f9b34fb"), // 常见变体6
|
||||
UUID.fromString("0000ff41-0000-1000-8000-00805f9b34fb"), // 常见变体7
|
||||
UUID.fromString("0000ff51-0000-1000-8000-00805f9b34fb"), // 常见变体8
|
||||
UUID.fromString("0000ff61-0000-1000-8000-00805f9b34fb"), // 常见变体9
|
||||
UUID.fromString("0000ff71-0000-1000-8000-00805f9b34fb"), // 常见变体10
|
||||
UUID.fromString("0000ff81-0000-1000-8000-00805f9b34fb"), // 常见变体11
|
||||
UUID.fromString("0000ff91-0000-1000-8000-00805f9b34fb"), // 常见变体12
|
||||
UUID.fromString("0000ffa1-0000-1000-8000-00805f9b34fb"), // 常见变体13
|
||||
UUID.fromString("0000ffb1-0000-1000-8000-00805f9b34fb") // 常见变体14
|
||||
)
|
||||
|
||||
// Notify特征UUID列表
|
||||
private val NOTIFY_CHARACTERISTIC_UUIDS = listOf(
|
||||
UUID.fromString("6e400003-b5a3-f393-e0a9-68716563686f"), // Nordic UART RX Characteristic - 你的设备
|
||||
UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"), // 默认Notify
|
||||
UUID.fromString("0000ffe2-0000-1000-8000-00805f9b34fb"), // 常见变体1 Notify
|
||||
UUID.fromString("0000ffe7-0000-1000-8000-00805f9b34fb"), // 常见变体2 Notify
|
||||
UUID.fromString("0000ff02-0000-1000-8000-00805f9b34fb"), // 常见变体3 Notify
|
||||
UUID.fromString("0000ff12-0000-1000-8000-00805f9b34fb"), // 常见变体4 Notify
|
||||
UUID.fromString("0000ff22-0000-1000-8000-00805f9b34fb"), // 常见变体5 Notify
|
||||
UUID.fromString("0000ff32-0000-1000-8000-00805f9b34fb"), // 常见变体6 Notify
|
||||
UUID.fromString("0000ff42-0000-1000-8000-00805f9b34fb"), // 常见变体7 Notify
|
||||
UUID.fromString("0000ff52-0000-1000-8000-00805f9b34fb"), // 常见变体8 Notify
|
||||
UUID.fromString("0000ff62-0000-1000-8000-00805f9b34fb"), // 常见变体9 Notify
|
||||
UUID.fromString("0000ff72-0000-1000-8000-00805f9b34fb"), // 常见变体10 Notify
|
||||
UUID.fromString("0000ff82-0000-1000-8000-00805f9b34fb"), // 常见变体11 Notify
|
||||
UUID.fromString("0000ff92-0000-1000-8000-00805f9b34fb"), // 常见变体12 Notify
|
||||
UUID.fromString("0000ffa2-0000-1000-8000-00805f9b34fb"), // 常见变体13 Notify
|
||||
UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb") // 常见变体14 Notify
|
||||
)
|
||||
|
||||
// 设备名称前缀 (根据你的设备调整)
|
||||
|
|
@ -179,6 +136,14 @@ class BluetoothManager(private val context: Context) {
|
|||
// 连接状态
|
||||
private var isConnected = false
|
||||
private var isConnecting = false
|
||||
private var isClassicScanReceiverRegistered = false
|
||||
|
||||
// Handler和延迟任务
|
||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
private var bleScanStopRunnable: Runnable? = null
|
||||
|
||||
// 清理状态标志
|
||||
private var isCleaningUp = false
|
||||
|
||||
// 设备列表
|
||||
private val discoveredDevices = mutableListOf<BluetoothDevice>()
|
||||
|
|
@ -360,10 +325,27 @@ class BluetoothManager(private val context: Context) {
|
|||
Log.d(TAG, "BLE扫描已开始")
|
||||
callback?.onStatusChanged("📡 BLE扫描进行中...")
|
||||
|
||||
// 取消之前的延迟任务(如果存在)
|
||||
bleScanStopRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
|
||||
// 15秒后停止扫描
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
stopBleScan()
|
||||
}, 15000)
|
||||
bleScanStopRunnable = Runnable {
|
||||
try {
|
||||
// 检查是否正在清理
|
||||
if (isCleaningUp) {
|
||||
Log.d(TAG, "正在清理中,跳过延迟BLE扫描停止")
|
||||
return@Runnable
|
||||
}
|
||||
stopBleScan()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "延迟停止BLE扫描失败: ${e.message}")
|
||||
// 如果是FragmentManager相关错误,记录但不抛出异常
|
||||
if (e.message?.contains("FragmentManager") == true) {
|
||||
Log.w(TAG, "FragmentManager已销毁,延迟BLE扫描停止操作被忽略")
|
||||
}
|
||||
}
|
||||
}
|
||||
mainHandler.postDelayed(bleScanStopRunnable!!, 15000)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BLE扫描失败: ${e.message}")
|
||||
|
|
@ -412,7 +394,19 @@ class BluetoothManager(private val context: Context) {
|
|||
* 停止BLE扫描
|
||||
*/
|
||||
private fun stopBleScan() {
|
||||
// 如果正在清理,直接返回
|
||||
if (isCleaningUp) {
|
||||
Log.d(TAG, "正在清理中,跳过BLE扫描停止")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 取消延迟任务
|
||||
bleScanStopRunnable?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
bleScanStopRunnable = null
|
||||
}
|
||||
|
||||
bluetoothLeScanner?.stopScan(bleScanCallback)
|
||||
Log.d(TAG, "BLE扫描已停止")
|
||||
|
||||
|
|
@ -433,6 +427,10 @@ class BluetoothManager(private val context: Context) {
|
|||
callback?.onScanComplete(discoveredDevices.toList())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "停止BLE扫描失败: ${e.message}")
|
||||
// 如果是FragmentManager相关错误,记录但不抛出异常
|
||||
if (e.message?.contains("FragmentManager") == true) {
|
||||
Log.w(TAG, "FragmentManager已销毁,BLE扫描停止操作被忽略")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1077,7 +1075,7 @@ class BluetoothManager(private val context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 直接连接到指定MAC地址的设备(用于测试)
|
||||
* 直接连接到指定MAC地址的设备
|
||||
*/
|
||||
fun connectToMacAddress(macAddress: String) {
|
||||
if (isConnecting || isConnected) {
|
||||
|
|
@ -1098,11 +1096,6 @@ class BluetoothManager(private val context: Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// 验证MAC地址格式
|
||||
if (!isValidMacAddress(macAddress)) {
|
||||
callback?.onError("MAC地址格式错误: $macAddress")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取设备
|
||||
val device = bluetoothAdapter!!.getRemoteDevice(macAddress)
|
||||
|
|
@ -1166,14 +1159,6 @@ class BluetoothManager(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证MAC地址格式
|
||||
*/
|
||||
private fun isValidMacAddress(macAddress: String): Boolean {
|
||||
// 支持多种格式:60:E9:AA:30:8B:0A 或 60-E9-AA-30-8B-0A
|
||||
val pattern = Regex("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
|
||||
return pattern.matches(macAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算CRC16-CCITT-FALSE校验
|
||||
|
|
@ -1557,12 +1542,23 @@ class BluetoothManager(private val context: Context) {
|
|||
* 注册传统蓝牙扫描广播接收器
|
||||
*/
|
||||
private fun registerClassicScanReceiver() {
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(BluetoothDevice.ACTION_FOUND)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
|
||||
try {
|
||||
if (!isClassicScanReceiverRegistered) {
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(BluetoothDevice.ACTION_FOUND)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
|
||||
}
|
||||
context.registerReceiver(classicScanReceiver, filter)
|
||||
isClassicScanReceiverRegistered = true
|
||||
Log.d(TAG, "传统蓝牙扫描广播接收器已注册")
|
||||
} else {
|
||||
Log.d(TAG, "传统蓝牙扫描广播接收器已注册,跳过重复注册")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "注册广播接收器失败: ${e.message}")
|
||||
isClassicScanReceiverRegistered = false
|
||||
}
|
||||
context.registerReceiver(classicScanReceiver, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1570,9 +1566,17 @@ class BluetoothManager(private val context: Context) {
|
|||
*/
|
||||
private fun unregisterClassicScanReceiver() {
|
||||
try {
|
||||
context.unregisterReceiver(classicScanReceiver)
|
||||
// 检查接收器是否已注册
|
||||
if (isClassicScanReceiverRegistered) {
|
||||
context.unregisterReceiver(classicScanReceiver)
|
||||
isClassicScanReceiverRegistered = false
|
||||
Log.d(TAG, "传统蓝牙扫描广播接收器已注销")
|
||||
} else {
|
||||
Log.d(TAG, "传统蓝牙扫描广播接收器未注册,跳过注销")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "注销广播接收器失败: ${e.message}")
|
||||
isClassicScanReceiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1650,8 +1654,117 @@ class BluetoothManager(private val context: Context) {
|
|||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
disconnect()
|
||||
stopScan()
|
||||
unregisterClassicScanReceiver()
|
||||
try {
|
||||
Log.d(TAG, "开始清理BluetoothManager资源")
|
||||
|
||||
// 设置清理标志,防止后续操作
|
||||
isCleaningUp = true
|
||||
|
||||
// 取消所有延迟任务
|
||||
bleScanStopRunnable?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
bleScanStopRunnable = null
|
||||
Log.d(TAG, "已取消BLE扫描延迟任务")
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
disconnect()
|
||||
|
||||
// 停止扫描(现在会检查isCleaningUp标志)
|
||||
stopScan()
|
||||
|
||||
// 注销广播接收器(如果已注册)
|
||||
if (isClassicScanReceiverRegistered) {
|
||||
unregisterClassicScanReceiver()
|
||||
}
|
||||
|
||||
// 清理状态
|
||||
isClassicScanReceiverRegistered = false
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
|
||||
Log.d(TAG, "BluetoothManager资源清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "清理BluetoothManager资源时发生异常: ${e.message}")
|
||||
} finally {
|
||||
// 确保清理标志被设置
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,21 +21,38 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
}
|
||||
|
||||
private var realTimeCallback: RealTimeDataCallback? = null
|
||||
private val additionalCallbacks = mutableListOf<RealTimeDataCallback>()
|
||||
|
||||
fun setRealTimeCallback(callback: RealTimeDataCallback) {
|
||||
this.realTimeCallback = callback
|
||||
}
|
||||
|
||||
fun addRealTimeCallback(callback: RealTimeDataCallback) {
|
||||
if (!additionalCallbacks.contains(callback)) {
|
||||
additionalCallbacks.add(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRealTimeCallback(callback: RealTimeDataCallback) {
|
||||
try {
|
||||
additionalCallbacks.remove(callback)
|
||||
Log.d("DataManager", "已移除实时数据回调,剩余回调数: ${additionalCallbacks.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "移除实时数据回调失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private var parserHandle: Long = 0L
|
||||
private val rawStream = ByteArrayOutputStream(4096)
|
||||
private val packetBuffer = mutableListOf<SensorData>()
|
||||
private val mappedPacketBuffer = mutableListOf<SensorData>() // 新增:映射后的数据包缓冲区
|
||||
private var totalPacketsParsed = 0L
|
||||
|
||||
// 信号处理相关
|
||||
private var streamingSignalProcessor: StreamingSignalProcessor? = null
|
||||
private var streamingSignalProcessorInitialized = false
|
||||
private val processedPackets = mutableListOf<SensorData>()
|
||||
private val filterSettings = FilterSettings()
|
||||
private var filterSettings = FilterSettings()
|
||||
|
||||
// 通道映射相关
|
||||
private var dataMapper: DataMapper? = null
|
||||
|
|
@ -65,7 +82,13 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
*/
|
||||
fun ensureParser() {
|
||||
if (parserHandle == 0L) {
|
||||
parserHandle = nativeCallback.createStreamParser()
|
||||
try {
|
||||
parserHandle = nativeCallback.createStreamParser()
|
||||
Log.d("DataManager", "JNI解析器创建成功,句柄: $parserHandle")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "JNI解析器创建失败: ${e.message}", e)
|
||||
throw RuntimeException("无法创建JNI解析器", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,10 +106,22 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
|
||||
ensureParser()
|
||||
rawStream.write(chunk)
|
||||
nativeCallback.streamParserAppend(parserHandle, chunk)
|
||||
|
||||
try {
|
||||
nativeCallback.streamParserAppend(parserHandle, chunk)
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "JNI数据追加失败: ${e.message}", e)
|
||||
return // 不继续处理,避免进一步错误
|
||||
}
|
||||
|
||||
// 拉取解析出的数据包
|
||||
val packets = nativeCallback.streamParserDrainPackets(parserHandle)
|
||||
val packets = try {
|
||||
nativeCallback.streamParserDrainPackets(parserHandle)
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "JNI数据拉取失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
Log.d("DataManager", "解析结果: ${if (packets != null) packets.size else 0} 个数据包")
|
||||
|
||||
if (!packets.isNullOrEmpty()) {
|
||||
|
|
@ -98,7 +133,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
// 立即发送原始数据到图表显示(优先显示原始数据)
|
||||
sendRawDataToCharts(packets)
|
||||
|
||||
// 可选:后台进行流式处理(不影响显示)
|
||||
// 注释掉后台处理,只显示原始数据
|
||||
// processStreamingData(packets)
|
||||
} else {
|
||||
Log.w("DataManager", "没有解析出有效数据包,尝试生成测试数据")
|
||||
|
|
@ -216,17 +251,98 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 立即发送原始数据到图表显示 - 专门处理单导联蓝牙数据
|
||||
* 立即发送映射后的数据到图表显示
|
||||
*/
|
||||
private fun sendRawDataToCharts(packets: List<SensorData>) {
|
||||
if (packets.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 快速处理:减少日志,提高速度
|
||||
// 确保数据映射器已初始化
|
||||
ensureDataMapper()
|
||||
if (!dataMapperInitialized || dataMapper == null) {
|
||||
Log.w("DataManager", "数据映射器未初始化,使用原始数据")
|
||||
sendRawDataToChartsDirect(packets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d("DataManager", "开始数据映射处理,输入数据包数: ${packets.size}")
|
||||
|
||||
// 清空之前的映射数据
|
||||
mappedPacketBuffer.clear()
|
||||
|
||||
// 对每个数据包进行映射
|
||||
val mappedPackets = mutableListOf<SensorData>()
|
||||
for (packet in packets) {
|
||||
val mappedPacket = dataMapper?.mapSensorData(packet)
|
||||
if (mappedPacket != null) {
|
||||
mappedPackets.add(mappedPacket)
|
||||
mappedPacketBuffer.add(mappedPacket) // 保存到映射缓冲区
|
||||
Log.d("DataManager", "数据包映射成功,原始通道数: ${packet.getChannelData()?.size ?: 0}, 映射后通道数: ${mappedPacket.getChannelData()?.size ?: 0}")
|
||||
} else {
|
||||
Log.w("DataManager", "数据包映射失败,使用原始数据")
|
||||
mappedPackets.add(packet)
|
||||
mappedPacketBuffer.add(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送映射后的数据到图表
|
||||
sendMappedDataToCharts(mappedPackets)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "数据映射处理失败: ${e.message}", e)
|
||||
// 映射失败时使用原始数据
|
||||
sendRawDataToChartsDirect(packets)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送映射后的数据到图表
|
||||
*/
|
||||
private fun sendMappedDataToCharts(mappedPackets: List<SensorData>) {
|
||||
if (mappedPackets.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
var totalDataPoints = 0
|
||||
|
||||
// 专门处理单导联数据包
|
||||
// 处理映射后的数据包
|
||||
for (packet in mappedPackets) {
|
||||
val channelData = packet.getChannelData()
|
||||
if (channelData != null && channelData.isNotEmpty()) {
|
||||
// 发送所有映射后的通道数据
|
||||
for (i in channelData.indices) {
|
||||
val channel = channelData[i]
|
||||
if (channel.isNotEmpty()) {
|
||||
// 发送给主回调
|
||||
realTimeCallback?.onRawDataAvailable(i, channel)
|
||||
// 发送给所有附加回调
|
||||
additionalCallbacks.forEach { callback ->
|
||||
callback.onRawDataAvailable(i, channel)
|
||||
}
|
||||
totalDataPoints += channel.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalDataPoints > 0) {
|
||||
Log.d("DataManager", "发送映射后数据到图表: ${totalDataPoints} 个数据点")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接发送原始数据到图表(备用方法)
|
||||
*/
|
||||
private fun sendRawDataToChartsDirect(packets: List<SensorData>) {
|
||||
if (packets.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
var totalDataPoints = 0
|
||||
|
||||
// 处理原始数据包
|
||||
for (packet in packets) {
|
||||
val channelData = packet.getChannelData()
|
||||
if (channelData != null && channelData.isNotEmpty()) {
|
||||
|
|
@ -234,10 +350,12 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
val primaryChannel = channelData[0]
|
||||
if (primaryChannel.isNotEmpty()) {
|
||||
// 立即发送单导联数据到图表
|
||||
if (realTimeCallback != null) {
|
||||
realTimeCallback!!.onRawDataAvailable(0, primaryChannel) // 使用通道0表示主导联
|
||||
totalDataPoints += primaryChannel.size
|
||||
realTimeCallback?.onRawDataAvailable(0, primaryChannel) // 使用通道0表示主导联
|
||||
// 发送给所有附加回调
|
||||
additionalCallbacks.forEach { callback ->
|
||||
callback.onRawDataAvailable(0, primaryChannel)
|
||||
}
|
||||
totalDataPoints += primaryChannel.size
|
||||
|
||||
// 如果有其他通道(如参考通道),也发送
|
||||
if (channelData.size > 1) {
|
||||
|
|
@ -245,6 +363,9 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
val additionalChannel = channelData[i]
|
||||
if (additionalChannel.isNotEmpty()) {
|
||||
realTimeCallback?.onRawDataAvailable(i, additionalChannel)
|
||||
additionalCallbacks.forEach { callback ->
|
||||
callback.onRawDataAvailable(i, additionalChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +375,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
|
||||
// 只在需要时记录日志
|
||||
if (totalDataPoints > 0) {
|
||||
Log.d("DataManager", "快速发送原始数据: ${totalDataPoints} 个数据点")
|
||||
Log.d("DataManager", "发送原始数据到图表: ${totalDataPoints} 个数据点")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,12 +514,12 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
Log.d("DataManager", "发送处理后的数据到图表,长度: ${processedData.size}")
|
||||
Log.d("DataManager", "处理后数据前3个值: ${processedData.take(3).joinToString(", ")}")
|
||||
|
||||
if (realTimeCallback != null) {
|
||||
realTimeCallback!!.onRawDataAvailable(0, processedData)
|
||||
Log.d("DataManager", "已发送处理后数据到图表")
|
||||
} else {
|
||||
Log.e("DataManager", "realTimeCallback 为空,无法发送处理后数据")
|
||||
realTimeCallback?.onRawDataAvailable(0, processedData)
|
||||
// 发送给所有附加回调
|
||||
additionalCallbacks.forEach { callback ->
|
||||
callback.onRawDataAvailable(0, processedData)
|
||||
}
|
||||
Log.d("DataManager", "已发送处理后数据到图表")
|
||||
}
|
||||
|
||||
// 计算指标(使用第一个处理后的通道)
|
||||
|
|
@ -566,40 +687,6 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓冲区
|
||||
*/
|
||||
fun cleanupBuffer() {
|
||||
try {
|
||||
// 清理原始数据包缓冲区,保留最新的100个数据包
|
||||
val maxKeepPackets = 100
|
||||
if (packetBuffer.size > maxKeepPackets) {
|
||||
val toRemove = packetBuffer.size - maxKeepPackets
|
||||
repeat(toRemove) {
|
||||
if (packetBuffer.isNotEmpty()) {
|
||||
packetBuffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("DataManager", "清理了 $toRemove 个旧数据包,保留 $maxKeepPackets 个最新数据包")
|
||||
}
|
||||
|
||||
// 清理处理后数据包缓冲区,保留最新的50个数据包
|
||||
val maxKeepProcessedPackets = 50
|
||||
if (processedPackets.size > maxKeepProcessedPackets) {
|
||||
val toRemove = processedPackets.size - maxKeepProcessedPackets
|
||||
repeat(toRemove) {
|
||||
if (processedPackets.isNotEmpty()) {
|
||||
processedPackets.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("DataManager", "清理了 $toRemove 个旧处理后数据包,保留 $maxKeepProcessedPackets 个最新数据包")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "清理缓冲区时发生错误: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算信号质量
|
||||
*/
|
||||
|
|
@ -670,7 +757,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
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 -> applyPPGFilters(channel)
|
||||
type.SensorData.DataType.PPG -> channel // PPG不进行滤波处理,直接返回原始数据
|
||||
else -> channel // 其他类型暂时不处理
|
||||
}
|
||||
processedChannels.add(processedChannel)
|
||||
|
|
@ -746,7 +833,8 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
val notchFiltered = streamingSignalProcessor!!.notchFilter(
|
||||
lowpassFiltered,
|
||||
filterSettings.sampleRate.toFloat(),
|
||||
50.0f
|
||||
50.0f,
|
||||
10.0f
|
||||
)
|
||||
|
||||
if (notchFiltered != null) {
|
||||
|
|
@ -762,6 +850,42 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
return channel // 处理失败时返回原始数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓冲区
|
||||
*/
|
||||
fun cleanupBuffer() {
|
||||
try {
|
||||
// 清理原始数据包缓冲区,保留最新的100个数据包
|
||||
val maxKeepPackets = 100
|
||||
if (packetBuffer.size > maxKeepPackets) {
|
||||
val toRemove = packetBuffer.size - maxKeepPackets
|
||||
repeat(toRemove) {
|
||||
if (packetBuffer.isNotEmpty()) {
|
||||
packetBuffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("DataManager", "清理了 $toRemove 个旧数据包,保留 $maxKeepPackets 个最新数据包")
|
||||
}
|
||||
|
||||
// 清理处理后数据包缓冲区,保留最新的50个数据包
|
||||
val maxKeepProcessedPackets = 50
|
||||
if (processedPackets.size > maxKeepProcessedPackets) {
|
||||
val toRemove = processedPackets.size - maxKeepProcessedPackets
|
||||
repeat(toRemove) {
|
||||
if (processedPackets.isNotEmpty()) {
|
||||
processedPackets.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("DataManager", "清理了 $toRemove 个旧处理后数据包,保留 $maxKeepProcessedPackets 个最新数据包")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "清理缓冲区时发生错误: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 应用PPG滤波器
|
||||
*/
|
||||
|
|
@ -796,18 +920,44 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
return channel // 处理失败时返回原始数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置滤波器参数
|
||||
*/
|
||||
fun setFilterSettings(settings: FilterSettings) {
|
||||
try {
|
||||
filterSettings = settings
|
||||
Log.d("DataManager", "滤波器设置已更新: $settings")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "设置滤波器参数失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前滤波器设置
|
||||
*/
|
||||
fun getFilterSettings(): FilterSettings {
|
||||
return filterSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
if (parserHandle != 0L) {
|
||||
nativeCallback.destroyStreamParser(parserHandle)
|
||||
parserHandle = 0L
|
||||
try {
|
||||
nativeCallback.destroyStreamParser(parserHandle)
|
||||
Log.d("DataManager", "JNI解析器销毁成功")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "JNI解析器销毁失败: ${e.message}", e)
|
||||
} finally {
|
||||
parserHandle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// 清理缓冲区
|
||||
packetBuffer.clear()
|
||||
mappedPacketBuffer.clear() // 清理映射后的数据包缓冲区
|
||||
processedPackets.clear()
|
||||
calculatedMetrics.clear()
|
||||
channelBuffers.clear()
|
||||
|
|
@ -820,9 +970,39 @@ class DataManager(private val nativeCallback: NativeMethodCallback) {
|
|||
lastProcessTime = 0L
|
||||
totalProcessedSamples = 0L
|
||||
|
||||
// 清理回调列表
|
||||
additionalCallbacks.clear()
|
||||
|
||||
Log.d("DataManager", "资源清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "清理资源时发生错误: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量级清理 - 只清理缓冲区,不销毁解析器
|
||||
*/
|
||||
fun lightCleanup() {
|
||||
try {
|
||||
// 只清理缓冲区,保持解析器运行
|
||||
packetBuffer.clear()
|
||||
mappedPacketBuffer.clear()
|
||||
processedPackets.clear()
|
||||
calculatedMetrics.clear()
|
||||
channelBuffers.clear()
|
||||
processedChannelBuffers.clear()
|
||||
rawStream.reset()
|
||||
|
||||
Log.d("DataManager", "轻量级清理完成,解析器保持运行")
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataManager", "轻量级清理时发生错误: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取映射后的数据包缓冲区
|
||||
*/
|
||||
fun getMappedPacketBuffer(): List<SensorData> {
|
||||
return mappedPacketBuffer.toList()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.util.Log
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 动态图表管理器
|
||||
* 负责根据设备类型动态创建和管理图表
|
||||
*/
|
||||
class DynamicChartManager(
|
||||
private val context: Context,
|
||||
private val chartsContainer: LinearLayout,
|
||||
private val chartTitle: TextView?
|
||||
) {
|
||||
|
||||
// 全屏按钮点击监听器
|
||||
private var onChartFullscreenClickListener: ((chartIndex: Int, chart: DynamicChartView) -> Unit)? = null
|
||||
|
||||
private val charts = mutableListOf<DynamicChartView>()
|
||||
private var currentDeviceType: SensorData.DataType? = null
|
||||
private var currentChannelCount = 0
|
||||
|
||||
/**
|
||||
* 设备配置信息
|
||||
*/
|
||||
data class DeviceConfig(
|
||||
val deviceName: String,
|
||||
val channelCount: Int,
|
||||
val sampleRate: Float,
|
||||
val timeWindow: Float,
|
||||
val maxDataPoints: Int,
|
||||
val channelNames: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据设备类型获取配置
|
||||
*/
|
||||
private fun getDeviceConfig(dataType: SensorData.DataType): DeviceConfig {
|
||||
return when (dataType) {
|
||||
SensorData.DataType.EEG -> DeviceConfig(
|
||||
deviceName = "脑电设备",
|
||||
channelCount = 8, // 6个EEG + 2个EOG
|
||||
sampleRate = 250f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 625, // 250Hz * 2.5s = 625个点
|
||||
channelNames = listOf("Fp1", "Fp2", "F3", "F4", "C3", "C4", "EOG1", "EOG2")
|
||||
)
|
||||
|
||||
SensorData.DataType.ECG_2LEAD -> DeviceConfig(
|
||||
deviceName = "双导联心电",
|
||||
channelCount = 6, // ECG1, ECG2, EMG1, EMG2, 呼吸温度, 呼吸阻抗
|
||||
sampleRate = 250f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 625, // 250Hz * 2.5s = 625个点
|
||||
channelNames = listOf("ECG1", "ECG2", "EMG1", "EMG2", "呼吸温度", "呼吸阻抗")
|
||||
)
|
||||
|
||||
SensorData.DataType.ECG_12LEAD -> DeviceConfig(
|
||||
deviceName = "12导联心电",
|
||||
channelCount = 12, // 映射后12个通道:I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6
|
||||
sampleRate = 250f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 625, // 250Hz * 2.5s = 625个点
|
||||
channelNames = listOf("I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6")
|
||||
)
|
||||
|
||||
SensorData.DataType.PW_ECG_SL -> DeviceConfig(
|
||||
deviceName = "单导联心电",
|
||||
channelCount = 1,
|
||||
sampleRate = 250f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 625, // 250Hz * 2.5s = 625个点
|
||||
channelNames = listOf("单导联ECG")
|
||||
)
|
||||
|
||||
SensorData.DataType.PPG -> DeviceConfig(
|
||||
deviceName = "血氧设备",
|
||||
channelCount = 1, // 两个通道合并为一张图
|
||||
sampleRate = 100f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 250, // 100Hz * 2.5s = 250个点
|
||||
channelNames = listOf("PPG信号") // 合并通道名称
|
||||
)
|
||||
|
||||
SensorData.DataType.RESPIRATION -> DeviceConfig(
|
||||
deviceName = "呼吸设备",
|
||||
channelCount = 1,
|
||||
sampleRate = 100f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 250, // 100Hz * 2.5s = 250个点
|
||||
channelNames = listOf("呼吸气流")
|
||||
)
|
||||
|
||||
SensorData.DataType.SNORE -> DeviceConfig(
|
||||
deviceName = "鼾声设备",
|
||||
channelCount = 1,
|
||||
sampleRate = 1000f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 2500, // 1000Hz * 2.5s = 2500个点
|
||||
channelNames = listOf("鼾声")
|
||||
)
|
||||
|
||||
SensorData.DataType.STETHOSCOPE -> DeviceConfig(
|
||||
deviceName = "数字听诊器",
|
||||
channelCount = 2,
|
||||
sampleRate = 1000f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 2500, // 1000Hz * 2.5s = 2500个点
|
||||
channelNames = listOf("听诊通道1", "听诊通道2")
|
||||
)
|
||||
|
||||
else -> DeviceConfig(
|
||||
deviceName = "未知设备",
|
||||
channelCount = 1,
|
||||
sampleRate = 250f,
|
||||
timeWindow = 2.5f,
|
||||
maxDataPoints = 625, // 250Hz * 2.5s = 625个点
|
||||
channelNames = listOf("通道")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备类型创建图表
|
||||
*/
|
||||
fun createChartsForDevice(dataType: SensorData.DataType) {
|
||||
val config = getDeviceConfig(dataType)
|
||||
|
||||
Log.d("DynamicChartManager", "=== 开始创建图表 ===")
|
||||
Log.d("DynamicChartManager", "设备类型: $dataType")
|
||||
Log.d("DynamicChartManager", "设备名称: ${config.deviceName}")
|
||||
Log.d("DynamicChartManager", "通道数量: ${config.channelCount}")
|
||||
Log.d("DynamicChartManager", "当前设备类型: $currentDeviceType")
|
||||
Log.d("DynamicChartManager", "当前通道数: $currentChannelCount")
|
||||
|
||||
// 如果设备类型没有变化,不需要重新创建
|
||||
if (currentDeviceType == dataType && currentChannelCount == config.channelCount) {
|
||||
Log.d("DynamicChartManager", "设备类型未变化,跳过创建")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("DynamicChartManager", "为设备 ${config.deviceName} 创建 ${config.channelCount} 个图表")
|
||||
|
||||
// 清除现有图表
|
||||
clearAllCharts()
|
||||
|
||||
// 更新标题(如果存在)
|
||||
chartTitle?.text = "${config.deviceName}实时监测"
|
||||
|
||||
// 特殊处理PPG设备:创建多通道单图表
|
||||
if (dataType == type.SensorData.DataType.PPG) {
|
||||
val chartView = DynamicChartView(context)
|
||||
|
||||
// 设置为多通道模式,原始有两个通道
|
||||
chartView.setMultiChannelMode(2)
|
||||
|
||||
// 设置图表配置
|
||||
chartView.setChartConfig(
|
||||
title = "红光PPG/红外光PPG",
|
||||
channelIdx = 1,
|
||||
device = config.deviceName,
|
||||
maxPoints = config.maxDataPoints,
|
||||
sampleRateHz = config.sampleRate,
|
||||
timeWindowSec = config.timeWindow
|
||||
)
|
||||
|
||||
// 设置布局参数
|
||||
val layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
marginStart = 8
|
||||
marginEnd = 8
|
||||
topMargin = 4
|
||||
bottomMargin = 4
|
||||
}
|
||||
|
||||
chartsContainer.addView(chartView, layoutParams)
|
||||
charts.add(chartView)
|
||||
|
||||
// 设置全屏按钮点击监听器
|
||||
chartView.setOnFullscreenClickListener {
|
||||
onChartFullscreenClickListener?.invoke(0, chartView)
|
||||
}
|
||||
|
||||
currentDeviceType = dataType
|
||||
currentChannelCount = config.channelCount
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新图表
|
||||
for (i in 0 until config.channelCount) {
|
||||
val chartView = DynamicChartView(context)
|
||||
|
||||
// 设置图表配置
|
||||
val channelName = if (i < config.channelNames.size) config.channelNames[i] else "通道"
|
||||
chartView.setChartConfig(
|
||||
title = channelName,
|
||||
channelIdx = i + 1,
|
||||
device = config.deviceName,
|
||||
maxPoints = config.maxDataPoints,
|
||||
sampleRateHz = config.sampleRate,
|
||||
timeWindowSec = config.timeWindow
|
||||
)
|
||||
|
||||
// 设置布局参数
|
||||
val layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
marginStart = 8
|
||||
marginEnd = 8
|
||||
topMargin = 4
|
||||
bottomMargin = 4
|
||||
}
|
||||
|
||||
// 添加分隔线(除了最后一个图表)
|
||||
if (i < config.channelCount - 1) {
|
||||
val separator = View(context).apply {
|
||||
setBackgroundColor(android.graphics.Color.parseColor("#CCCCCC"))
|
||||
}
|
||||
val separatorParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
2
|
||||
).apply {
|
||||
marginStart = 8
|
||||
marginEnd = 8
|
||||
topMargin = 4
|
||||
bottomMargin = 4
|
||||
}
|
||||
chartsContainer.addView(separator, separatorParams)
|
||||
}
|
||||
|
||||
chartsContainer.addView(chartView, layoutParams)
|
||||
charts.add(chartView)
|
||||
|
||||
// 设置全屏按钮点击监听器
|
||||
val chartIndex = i
|
||||
chartView.setOnFullscreenClickListener {
|
||||
onChartFullscreenClickListener?.invoke(chartIndex, chartView)
|
||||
}
|
||||
}
|
||||
|
||||
currentDeviceType = dataType
|
||||
currentChannelCount = config.channelCount
|
||||
|
||||
Log.d("DynamicChartManager", "=== 图表创建完成 ===")
|
||||
Log.d("DynamicChartManager", "成功创建 ${charts.size} 个图表")
|
||||
Log.d("DynamicChartManager", "图表容器子视图数量: ${chartsContainer.childCount}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图表数据
|
||||
*/
|
||||
fun updateChartsData(channelData: List<List<Float>>) {
|
||||
Log.d("DynamicChartManager", "=== 开始更新图表数据 ===")
|
||||
Log.d("DynamicChartManager", "图表数量: ${charts.size}")
|
||||
Log.d("DynamicChartManager", "数据通道数: ${channelData.size}")
|
||||
|
||||
if (charts.isEmpty() || channelData.isEmpty()) {
|
||||
Log.w("DynamicChartManager", "图表为空或数据为空,跳过更新")
|
||||
return
|
||||
}
|
||||
|
||||
// 性能优化:减少日志输出
|
||||
val dataChannels = channelData.size
|
||||
val chartCount = charts.size
|
||||
|
||||
Log.d("DynamicChartManager", "数据通道: $dataChannels, 图表数量: $chartCount")
|
||||
|
||||
// 特殊处理PPG设备的多通道数据
|
||||
if (currentDeviceType == type.SensorData.DataType.PPG && chartCount == 1) {
|
||||
Log.d("DynamicChartManager", "PPG多通道数据更新: 通道数=${channelData.size}, 数据点=${channelData.map { it.size }}")
|
||||
// PPG设备只有一个图表,但有两个通道的数据
|
||||
val chart = charts[0]
|
||||
chart.updateMultiChannelData(channelData)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新每个图表的数据 - 并行处理
|
||||
for (i in 0 until minOf(dataChannels, chartCount)) {
|
||||
val chart = charts[i]
|
||||
val data = channelData[i]
|
||||
chart.updateData(data)
|
||||
}
|
||||
|
||||
// 如果数据通道少于图表,清空多余的图表
|
||||
if (dataChannels < chartCount) {
|
||||
for (i in dataChannels until chartCount) {
|
||||
charts[i].clearData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有图表
|
||||
*/
|
||||
fun clearAllCharts() {
|
||||
try {
|
||||
Log.d("DynamicChartManager", "=== 开始清空所有图表 ===")
|
||||
Log.d("DynamicChartManager", "当前图表数量: ${charts.size}")
|
||||
Log.d("DynamicChartManager", "容器子视图数量: ${chartsContainer.childCount}")
|
||||
|
||||
if (charts.isEmpty()) {
|
||||
Log.d("DynamicChartManager", "图表列表为空,跳过清理")
|
||||
return
|
||||
}
|
||||
|
||||
// 清理图表数据
|
||||
charts.forEachIndexed { index, chart ->
|
||||
try {
|
||||
Log.d("DynamicChartManager", "清理图表 $index")
|
||||
Log.d("DynamicChartManager", "图表类型: ${chart.javaClass.simpleName}")
|
||||
|
||||
// 清理图表数据
|
||||
chart.clearData()
|
||||
Log.d("DynamicChartManager", "图表 $index 数据清理完成")
|
||||
|
||||
// 清理触摸事件处理器
|
||||
chart.cleanupTouchHandlers()
|
||||
Log.d("DynamicChartManager", "图表 $index 触摸处理器清理完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartManager", "清理图表 $index 失败: ${e.message}", e)
|
||||
Log.e("DynamicChartManager", "异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
// 清空图表列表
|
||||
charts.clear()
|
||||
Log.d("DynamicChartManager", "图表列表已清空")
|
||||
|
||||
// 移除所有子视图
|
||||
chartsContainer.removeAllViews()
|
||||
Log.d("DynamicChartManager", "容器子视图已移除")
|
||||
|
||||
// 重置状态
|
||||
currentDeviceType = null
|
||||
currentChannelCount = 0
|
||||
Log.d("DynamicChartManager", "状态已重置")
|
||||
|
||||
Log.d("DynamicChartManager", "=== 清空所有图表完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartManager", "清空图表时发生异常: ${e.message}", e)
|
||||
Log.e("DynamicChartManager", "异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有图表视口
|
||||
*/
|
||||
fun resetAllCharts() {
|
||||
charts.forEach { chart ->
|
||||
chart.resetViewport()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图表列表
|
||||
*/
|
||||
fun getCharts(): List<DynamicChartView> = charts.toList()
|
||||
|
||||
/**
|
||||
* 获取当前设备类型
|
||||
*/
|
||||
fun getCurrentDeviceType(): SensorData.DataType? = currentDeviceType
|
||||
|
||||
/**
|
||||
* 获取当前通道数量
|
||||
*/
|
||||
fun getCurrentChannelCount(): Int = currentChannelCount
|
||||
|
||||
/**
|
||||
* 获取当前设备配置
|
||||
*/
|
||||
fun getCurrentDeviceConfig(): DeviceConfig? {
|
||||
return currentDeviceType?.let { getDeviceConfig(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置图表全屏按钮点击监听器
|
||||
*/
|
||||
fun setOnChartFullscreenClickListener(listener: (chartIndex: Int, chart: DynamicChartView) -> Unit) {
|
||||
onChartFullscreenClickListener = listener
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,954 @@
|
|||
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.View
|
||||
import android.view.ScaleGestureDetector
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 可调节的通用生理信号图表视图
|
||||
* 支持多种设备类型和通道数量的动态显示,具有缩放、平移、时间窗口调节等功能
|
||||
*/
|
||||
class DynamicChartView @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 = false // 关闭抗锯齿,提高绘制性能
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
style = Paint.Style.STROKE
|
||||
alpha = 80
|
||||
}
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
textSize = 20f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val controlPaint = Paint().apply {
|
||||
color = Color.RED
|
||||
strokeWidth = 3f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
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 isDataAvailable = false
|
||||
|
||||
// 累积数据点计数器
|
||||
private var totalDataPointsReceived = 0L
|
||||
|
||||
// 全屏模式和自定义Y轴范围
|
||||
private var isFullscreenMode = false
|
||||
private var customMinValue: Float? = null
|
||||
private var customMaxValue: Float? = null
|
||||
|
||||
// 全屏按钮
|
||||
private var fullscreenButtonRect = RectF()
|
||||
private var showFullscreenButton = true
|
||||
private var onFullscreenClickListener: (() -> Unit)? = null
|
||||
|
||||
// 图表配置
|
||||
private var chartTitle = "通道"
|
||||
private var channelIndex = 0
|
||||
private var deviceType = "未知设备"
|
||||
private var sampleRate = 250f // 默认采样率
|
||||
private var timeWindow = 4f // 默认时间窗口(秒)
|
||||
|
||||
// 简化的数据管理:直接显示新数据
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
|
||||
// 多通道数据支持(用于PPG等设备)
|
||||
private val multiChannelDataPoints = mutableListOf<MutableList<Float>>()
|
||||
private val multiChannelDataBuffers = mutableListOf<MutableList<Float>>()
|
||||
private var isMultiChannelMode = false
|
||||
private val channelColors = listOf(Color.RED, Color.BLUE) // 通道颜色
|
||||
|
||||
// 交互控制参数
|
||||
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())
|
||||
|
||||
// 控制按钮区域
|
||||
private val controlButtonRect = RectF()
|
||||
|
||||
/**
|
||||
* 设置图表配置
|
||||
*/
|
||||
fun setChartConfig(
|
||||
title: String,
|
||||
channelIdx: Int,
|
||||
device: String,
|
||||
maxPoints: Int,
|
||||
sampleRateHz: Float,
|
||||
timeWindowSec: Float
|
||||
) {
|
||||
chartTitle = title
|
||||
channelIndex = channelIdx
|
||||
deviceType = device
|
||||
maxDataPoints = optimizeMaxDataPoints(maxPoints, sampleRateHz)
|
||||
sampleRate = sampleRateHz
|
||||
timeWindow = timeWindowSec
|
||||
resetViewport()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备类型和内存情况优化最大数据点数量
|
||||
*/
|
||||
private fun optimizeMaxDataPoints(baseMaxPoints: Int, sampleRate: Float): Int {
|
||||
try {
|
||||
val runtime = Runtime.getRuntime()
|
||||
val maxMemoryMB = runtime.maxMemory() / 1024 / 1024
|
||||
|
||||
var optimizedPoints = baseMaxPoints
|
||||
|
||||
// 根据采样率调整
|
||||
optimizedPoints = when {
|
||||
sampleRate >= 8000f -> (baseMaxPoints * 0.3).toInt() // 音频设备减少到30%
|
||||
sampleRate >= 1000f -> (baseMaxPoints * 0.5).toInt() // 高速设备减少到50%
|
||||
sampleRate >= 250f -> baseMaxPoints // ECG等保持不变
|
||||
else -> (baseMaxPoints * 1.2).toInt() // 低速设备可以稍微增加
|
||||
}
|
||||
|
||||
// 根据内存情况调整
|
||||
optimizedPoints = when {
|
||||
maxMemoryMB < 128 -> (optimizedPoints * 0.5).toInt() // 低内存设备
|
||||
maxMemoryMB < 256 -> (optimizedPoints * 0.75).toInt() // 中等内存设备
|
||||
maxMemoryMB >= 512 -> (optimizedPoints * 1.25).toInt() // 高内存设备
|
||||
else -> optimizedPoints
|
||||
}
|
||||
|
||||
// 确保在合理范围内
|
||||
optimizedPoints = optimizedPoints.coerceIn(500, 10000)
|
||||
|
||||
Log.d("DynamicChartView", "数据点限制优化: 原始=$baseMaxPoints, 优化后=$optimizedPoints, 采样率=${sampleRate}Hz, 内存=${maxMemoryMB}MB")
|
||||
return optimizedPoints
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "数据点限制优化失败: ${e.message}")
|
||||
return baseMaxPoints
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全屏模式
|
||||
*/
|
||||
fun setFullscreenMode(isFullscreen: Boolean) {
|
||||
isFullscreenMode = isFullscreen
|
||||
// 全屏模式下隐藏全屏按钮
|
||||
showFullscreenButton = !isFullscreen
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自定义Y轴范围
|
||||
*/
|
||||
fun setCustomYRange(min: Float, max: Float) {
|
||||
customMinValue = min
|
||||
customMaxValue = max
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除自定义Y轴范围,恢复自动计算
|
||||
*/
|
||||
fun clearCustomYRange() {
|
||||
customMinValue = null
|
||||
customMaxValue = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全屏按钮点击监听器
|
||||
*/
|
||||
fun setOnFullscreenClickListener(listener: () -> Unit) {
|
||||
onFullscreenClickListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否显示全屏按钮
|
||||
*/
|
||||
fun setShowFullscreenButton(show: Boolean) {
|
||||
showFullscreenButton = show
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置多通道模式(用于PPG等设备)
|
||||
*/
|
||||
fun setMultiChannelMode(channelCount: Int) {
|
||||
isMultiChannelMode = true
|
||||
multiChannelDataPoints.clear()
|
||||
multiChannelDataBuffers.clear()
|
||||
|
||||
// 初始化每个通道的数据结构
|
||||
for (i in 0 until channelCount) {
|
||||
multiChannelDataPoints.add(mutableListOf())
|
||||
multiChannelDataBuffers.add(mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 更新累积数据点计数器
|
||||
totalDataPointsReceived += newData.size
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
val excessData = dataPoints.size - maxDataPoints
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
Log.d("DynamicChartView", "清理了 $excessData 个数据点,当前数据点: ${dataPoints.size}")
|
||||
}
|
||||
|
||||
// 定期检查内存使用情况
|
||||
checkMemoryUsage()
|
||||
|
||||
// 更新数据范围
|
||||
updateDataRange(newData)
|
||||
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查内存使用情况,必要时进行清理
|
||||
*/
|
||||
private fun checkMemoryUsage() {
|
||||
try {
|
||||
// 每1000个数据点检查一次内存使用情况
|
||||
if (totalDataPointsReceived % 1000 == 0L) {
|
||||
val runtime = Runtime.getRuntime()
|
||||
val usedMemory = runtime.totalMemory() - runtime.freeMemory()
|
||||
val maxMemory = runtime.maxMemory()
|
||||
val memoryUsagePercent = (usedMemory.toDouble() / maxMemory.toDouble()) * 100
|
||||
|
||||
Log.d("DynamicChartView", "内存使用情况: ${usedMemory / 1024 / 1024}MB/${maxMemory / 1024 / 1024}MB (${String.format("%.1f", memoryUsagePercent)}%)")
|
||||
|
||||
// 如果内存使用超过80%,强制清理
|
||||
if (memoryUsagePercent > 80.0) {
|
||||
Log.w("DynamicChartView", "内存使用过高 (${String.format("%.1f", memoryUsagePercent)}%),执行紧急清理")
|
||||
emergencyCleanup()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "检查内存使用情况失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧急内存清理
|
||||
*/
|
||||
private fun emergencyCleanup() {
|
||||
try {
|
||||
Log.d("DynamicChartView", "=== 执行紧急内存清理 ===")
|
||||
|
||||
// 强制清理数据点,只保留最新的50%数据
|
||||
val keepSize = (dataPoints.size * 0.5).toInt()
|
||||
if (keepSize > 0 && dataPoints.size > keepSize) {
|
||||
dataPoints = dataPoints.takeLast(keepSize).toMutableList()
|
||||
Log.d("DynamicChartView", "紧急清理数据点,保留 $keepSize 个数据点")
|
||||
}
|
||||
|
||||
// 清理多通道数据
|
||||
multiChannelDataPoints.forEachIndexed { index, channelData ->
|
||||
val keepChannelSize = (channelData.size * 0.5).toInt()
|
||||
if (keepChannelSize > 0 && channelData.size > keepChannelSize) {
|
||||
multiChannelDataPoints[index] = channelData.takeLast(keepChannelSize).toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
// 重新计算数据范围
|
||||
updateDataRange(dataPoints)
|
||||
|
||||
// 强制垃圾回收
|
||||
System.gc()
|
||||
|
||||
Log.d("DynamicChartView", "=== 紧急内存清理完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "紧急内存清理失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前数据点数量
|
||||
*/
|
||||
fun getDataPointCount(): Int {
|
||||
return if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.sumOf { it.size }
|
||||
} else {
|
||||
dataPoints.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪数据到指定大小
|
||||
*/
|
||||
fun trimDataToSize(maxSize: Int) {
|
||||
try {
|
||||
if (isMultiChannelMode) {
|
||||
// 多通道模式
|
||||
multiChannelDataPoints.forEachIndexed { index, channelData ->
|
||||
if (channelData.size > maxSize) {
|
||||
multiChannelDataPoints[index] = channelData.takeLast(maxSize).toMutableList()
|
||||
}
|
||||
}
|
||||
// 重新计算数据范围
|
||||
updateMultiChannelDataRange()
|
||||
} else {
|
||||
// 单通道模式
|
||||
if (dataPoints.size > maxSize) {
|
||||
dataPoints = dataPoints.takeLast(maxSize).toMutableList()
|
||||
// 重新计算数据范围
|
||||
updateDataRange(dataPoints)
|
||||
}
|
||||
}
|
||||
|
||||
// 重绘
|
||||
invalidate()
|
||||
Log.d("DynamicChartView", "数据已裁剪到 $maxSize 个点")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "裁剪数据失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新多通道数据(用于PPG等设备)
|
||||
*/
|
||||
fun updateMultiChannelData(channelData: List<List<Float>>) {
|
||||
if (channelData.isEmpty() || !isMultiChannelMode) {
|
||||
Log.d("DynamicChartView", "跳过多通道更新: channelData.isEmpty=${channelData.isEmpty()}, isMultiChannelMode=$isMultiChannelMode")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("DynamicChartView", "多通道数据更新: 通道数=${channelData.size}, 每个通道数据点=${channelData.map { it.size }}")
|
||||
isDataAvailable = true
|
||||
|
||||
// 更新累积数据点计数器(使用第一个通道的数据点数量)
|
||||
if (channelData.isNotEmpty()) {
|
||||
totalDataPointsReceived += channelData[0].size
|
||||
}
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
for (channelIndex in channelData.indices) {
|
||||
if (channelIndex >= multiChannelDataPoints.size) {
|
||||
Log.w("DynamicChartView", "通道索引超出范围: $channelIndex >= ${multiChannelDataPoints.size}")
|
||||
continue
|
||||
}
|
||||
|
||||
val channelDataPoints = multiChannelDataPoints[channelIndex]
|
||||
channelDataPoints.addAll(channelData[channelIndex])
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (channelDataPoints.size > maxDataPoints) {
|
||||
multiChannelDataPoints[channelIndex] = channelDataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
Log.d("DynamicChartView", "通道$channelIndex 添加了${channelData[channelIndex].size}个数据点")
|
||||
}
|
||||
|
||||
// 更新数据范围 - 计算所有通道的数据范围
|
||||
updateMultiChannelDataRange()
|
||||
|
||||
Log.d("DynamicChartView", "多通道数据处理完成: 通道数据点=${multiChannelDataPoints.map { it.size }}, 范围=${minValue}-${maxValue}")
|
||||
|
||||
// 检查内存使用情况
|
||||
checkMemoryUsage()
|
||||
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateDataRange(newData: List<Float>) {
|
||||
// 如果设置了自定义Y轴范围,使用自定义范围
|
||||
if (customMinValue != null && customMaxValue != null) {
|
||||
minValue = customMinValue!!
|
||||
maxValue = customMaxValue!!
|
||||
return
|
||||
}
|
||||
|
||||
// 使用全部数据点计算范围,提供更准确的范围
|
||||
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 {
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新多通道数据范围
|
||||
*/
|
||||
private fun updateMultiChannelDataRange() {
|
||||
// 如果设置了自定义Y轴范围,使用自定义范围
|
||||
if (customMinValue != null && customMaxValue != null) {
|
||||
minValue = customMinValue!!
|
||||
maxValue = customMaxValue!!
|
||||
Log.d("DynamicChartView", "使用自定义Y轴范围: ${minValue}-${maxValue}")
|
||||
return
|
||||
}
|
||||
|
||||
if (multiChannelDataPoints.isEmpty()) {
|
||||
Log.w("DynamicChartView", "多通道数据点为空,无法计算范围")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算所有通道的最小值和最大值
|
||||
var globalMin = Float.MAX_VALUE
|
||||
var globalMax = Float.MIN_VALUE
|
||||
var hasValidData = false
|
||||
|
||||
for (channelIndex in multiChannelDataPoints.indices) {
|
||||
val channelData = multiChannelDataPoints[channelIndex]
|
||||
if (channelData.isNotEmpty()) {
|
||||
val channelMin = channelData.minOrNull() ?: 0f
|
||||
val channelMax = channelData.maxOrNull() ?: 0f
|
||||
globalMin = minOf(globalMin, channelMin)
|
||||
globalMax = maxOf(globalMax, channelMax)
|
||||
hasValidData = true
|
||||
Log.d("DynamicChartView", "通道$channelIndex: 范围=${channelMin}-${channelMax}, 数据点=${channelData.size}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidData) {
|
||||
Log.w("DynamicChartView", "没有有效的多通道数据")
|
||||
return
|
||||
}
|
||||
|
||||
minValue = globalMin
|
||||
maxValue = globalMax
|
||||
|
||||
Log.d("DynamicChartView", "多通道全局范围: ${minValue}-${maxValue}")
|
||||
|
||||
// 确保有足够的显示范围
|
||||
val range = maxValue - minValue
|
||||
if (range < 0.1f) {
|
||||
val center = (maxValue + minValue) / 2
|
||||
minValue = center - 0.05f
|
||||
maxValue = center + 0.05f
|
||||
} else {
|
||||
val margin = range * 0.1f
|
||||
minValue -= margin
|
||||
maxValue += margin
|
||||
}
|
||||
|
||||
Log.d("DynamicChartView", "多通道显示范围: ${minValue}-${maxValue}")
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
try {
|
||||
Log.d("DynamicChartView", "=== 开始清理图表数据 ===")
|
||||
|
||||
// 清理单通道数据
|
||||
dataPoints.clear()
|
||||
dataBuffer.clear()
|
||||
Log.d("DynamicChartView", "单通道数据已清理")
|
||||
|
||||
// 清除多通道数据
|
||||
multiChannelDataPoints.forEachIndexed { index, channelData ->
|
||||
try {
|
||||
channelData.clear()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "清理多通道数据 $index 失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
multiChannelDataBuffers.forEachIndexed { index, channelBuffer ->
|
||||
try {
|
||||
channelBuffer.clear()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "清理多通道缓冲区 $index 失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
Log.d("DynamicChartView", "多通道数据已清理")
|
||||
|
||||
// 重置状态
|
||||
isDataAvailable = false
|
||||
totalDataPointsReceived = 0L // 重置累积数据点计数器
|
||||
Log.d("DynamicChartView", "数据状态已重置")
|
||||
|
||||
// 重置视口
|
||||
resetViewport()
|
||||
Log.d("DynamicChartView", "视口已重置")
|
||||
|
||||
// 重绘
|
||||
invalidate()
|
||||
Log.d("DynamicChartView", "=== 图表数据清理完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "清理图表数据时发生异常: ${e.message}", e)
|
||||
Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理触摸事件处理器
|
||||
*/
|
||||
fun cleanupTouchHandlers() {
|
||||
try {
|
||||
Log.d("DynamicChartView", "=== 开始清理触摸事件处理器 ===")
|
||||
|
||||
// 重置触摸状态
|
||||
isDragging = false
|
||||
lastTouchX = 0f
|
||||
lastTouchY = 0f
|
||||
|
||||
// 清理全屏按钮监听器
|
||||
onFullscreenClickListener = null
|
||||
|
||||
Log.d("DynamicChartView", "=== 触摸事件处理器清理完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "清理触摸事件处理器时发生异常: ${e.message}", e)
|
||||
Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当视图从窗口分离时调用
|
||||
*/
|
||||
override fun onDetachedFromWindow() {
|
||||
try {
|
||||
Log.d("DynamicChartView", "=== onDetachedFromWindow 开始 ===")
|
||||
|
||||
// 清理触摸事件处理器
|
||||
cleanupTouchHandlers()
|
||||
|
||||
// 清理数据
|
||||
clearData()
|
||||
|
||||
Log.d("DynamicChartView", "=== onDetachedFromWindow 完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "onDetachedFromWindow 异常: ${e.message}", e)
|
||||
Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}")
|
||||
} finally {
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置视口到默认状态
|
||||
*/
|
||||
fun resetViewport() {
|
||||
scaleFactor = 1.0f
|
||||
translateX = 0f
|
||||
translateY = 0f
|
||||
viewportStart = 0f
|
||||
viewportEnd = 1.0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
|
||||
// 检查是否点击了全屏按钮
|
||||
if (showFullscreenButton && fullscreenButtonRect.contains(x, y)) {
|
||||
onFullscreenClickListener?.invoke()
|
||||
return true
|
||||
}
|
||||
|
||||
// 开始拖拽
|
||||
isDragging = true
|
||||
lastTouchX = x
|
||||
lastTouchY = y
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (isDragging) {
|
||||
val deltaX = event.x - lastTouchX
|
||||
val deltaY = event.y - lastTouchY
|
||||
|
||||
translateX += deltaX
|
||||
translateY += deltaY
|
||||
|
||||
// 限制平移范围
|
||||
val maxTranslate = width * 0.5f
|
||||
translateX = translateX.coerceIn(-maxTranslate, maxTranslate)
|
||||
translateY = translateY.coerceIn(-maxTranslate, maxTranslate)
|
||||
|
||||
lastTouchX = event.x
|
||||
lastTouchY = event.y
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
isDragging = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
scaleFactor *= detector.scaleFactor
|
||||
scaleFactor = scaleFactor.coerceIn(0.2f, 5.0f)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
try {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 应用变换
|
||||
canvas.save()
|
||||
canvas.translate(translateX, translateY)
|
||||
canvas.scale(scaleFactor, scaleFactor, width / 2, height / 2)
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(canvas, width, height)
|
||||
|
||||
// 绘制标题和通道信息
|
||||
drawTitle(canvas, width, height)
|
||||
|
||||
// 绘制数据统计
|
||||
drawStats(canvas, width, height)
|
||||
|
||||
// 绘制全屏按钮
|
||||
if (showFullscreenButton) {
|
||||
drawFullscreenButton(canvas, width, height)
|
||||
}
|
||||
|
||||
// 如果没有数据,显示默认内容
|
||||
if (!isDataAvailable) {
|
||||
drawDefaultContent(canvas, width, height)
|
||||
} else {
|
||||
// 绘制曲线 - 检查单通道或多通道数据
|
||||
val hasDataToDraw = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.any { it.size > 1 }
|
||||
} else {
|
||||
dataPoints.size > 1
|
||||
}
|
||||
|
||||
if (hasDataToDraw) {
|
||||
drawCurve(canvas, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复画布状态
|
||||
canvas.restore()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DynamicChartView", "绘制时发生异常: ${e.message}", e)
|
||||
// 确保画布状态被恢复
|
||||
try {
|
||||
canvas.restore()
|
||||
} catch (e2: Exception) {
|
||||
Log.e("DynamicChartView", "恢复画布状态失败: ${e2.message}", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||||
val padding = if (isFullscreenMode) 20f else 60f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
// 绘制水平网格线和刻度
|
||||
val gridLines = 5
|
||||
for (i in 0..gridLines) {
|
||||
val y = padding + (drawHeight * i / gridLines)
|
||||
canvas.drawLine(padding, y, width - padding, y, gridPaint)
|
||||
|
||||
// 只在全屏模式下绘制Y轴刻度值
|
||||
if (isFullscreenMode) {
|
||||
val value = maxValue - (maxValue - minValue) * i / gridLines
|
||||
val valueText = String.format("%.1f", value)
|
||||
canvas.drawText(valueText, 5f, y + 5f, textPaint)
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制垂直网格线和刻度
|
||||
for (i in 0..gridLines) {
|
||||
val x = padding + (drawWidth * i / gridLines)
|
||||
canvas.drawLine(x, padding, x, height - padding, gridPaint)
|
||||
|
||||
// 只在全屏模式下绘制X轴刻度值(累积数据点索引)
|
||||
if (isFullscreenMode) {
|
||||
val dataPointIndex = if (isMultiChannelMode) {
|
||||
// 多通道模式:使用累积数据点计数器
|
||||
val stepSize = totalDataPointsReceived.toFloat() / gridLines
|
||||
(stepSize * i).toInt()
|
||||
} else {
|
||||
// 单通道模式:使用累积数据点计数器
|
||||
val stepSize = totalDataPointsReceived.toFloat() / gridLines
|
||||
(stepSize * i).toInt()
|
||||
}
|
||||
val indexText = dataPointIndex.toString()
|
||||
canvas.drawText(indexText, x - 10f, height - 10f, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawTitle(canvas: Canvas, width: Float, height: Float) {
|
||||
val title = "$chartTitle $channelIndex"
|
||||
canvas.drawText(title, 10f, 25f, textPaint)
|
||||
|
||||
val deviceInfo = "$deviceType (${sampleRate.toInt()}Hz)"
|
||||
canvas.drawText(deviceInfo, 10f, 50f, textPaint)
|
||||
|
||||
// 显示当前缩放和时间窗口信息
|
||||
val scaleInfo = "缩放: ${String.format("%.1f", scaleFactor)}x"
|
||||
canvas.drawText(scaleInfo, width - 150f, 25f, textPaint)
|
||||
|
||||
val timeInfo = "时间窗口: ${timeWindow.toInt()}秒"
|
||||
canvas.drawText(timeInfo, width - 150f, 50f, textPaint)
|
||||
}
|
||||
|
||||
private fun drawStats(canvas: Canvas, width: Float, height: Float) {
|
||||
val totalDataPoints = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.sumOf { it.size }
|
||||
} else {
|
||||
dataPoints.size
|
||||
}
|
||||
|
||||
val statsText = if (isMultiChannelMode) {
|
||||
"显示数据点: ${totalDataPoints} (${multiChannelDataPoints.size}通道) | 累积: ${totalDataPointsReceived}"
|
||||
} else {
|
||||
"显示数据点: ${totalDataPoints} | 累积: ${totalDataPointsReceived}"
|
||||
}
|
||||
canvas.drawText(statsText, 10f, height - 35f, textPaint)
|
||||
|
||||
if (totalDataPoints > 0) {
|
||||
val rangeText = if (customMinValue != null && customMaxValue != null) {
|
||||
"Y轴范围: ${String.format("%.0f", minValue)} - ${String.format("%.0f", maxValue)} (固定)"
|
||||
} else {
|
||||
"Y轴范围: ${String.format("%.2f", minValue)} - ${String.format("%.2f", maxValue)} (自动)"
|
||||
}
|
||||
canvas.drawText(rangeText, 10f, height - 15f, textPaint)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 16f
|
||||
textAlign = Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示数据", centerX, centerY + 60f, hintPaint)
|
||||
}
|
||||
|
||||
private fun drawFullscreenButton(canvas: Canvas, width: Float, height: Float) {
|
||||
val buttonSize = 40f
|
||||
val margin = 10f
|
||||
val buttonX = width - buttonSize - margin
|
||||
val buttonY = margin
|
||||
|
||||
// 设置按钮区域
|
||||
fullscreenButtonRect.set(buttonX, buttonY, buttonX + buttonSize, buttonY + buttonSize)
|
||||
|
||||
// 绘制按钮背景
|
||||
val buttonPaint = Paint().apply {
|
||||
color = Color.parseColor("#4CAF50")
|
||||
style = Paint.Style.FILL
|
||||
isAntiAlias = true
|
||||
}
|
||||
canvas.drawRoundRect(fullscreenButtonRect, 8f, 8f, buttonPaint)
|
||||
|
||||
// 绘制按钮边框
|
||||
val borderPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2f
|
||||
isAntiAlias = true
|
||||
}
|
||||
canvas.drawRoundRect(fullscreenButtonRect, 8f, 8f, borderPaint)
|
||||
|
||||
// 绘制全屏图标(简单的矩形)
|
||||
val iconPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
val iconMargin = 8f
|
||||
val iconRect = RectF(
|
||||
buttonX + iconMargin,
|
||||
buttonY + iconMargin,
|
||||
buttonX + buttonSize - iconMargin,
|
||||
buttonY + buttonSize - iconMargin
|
||||
)
|
||||
canvas.drawRect(iconRect, iconPaint)
|
||||
|
||||
// 绘制全屏文字
|
||||
val textPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
textSize = 10f
|
||||
textAlign = Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
}
|
||||
canvas.drawText("全屏", buttonX + buttonSize/2, buttonY + buttonSize/2 + 3f, textPaint)
|
||||
}
|
||||
|
||||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||||
val padding = if (isFullscreenMode) 20f else 60f
|
||||
val drawWidth = width - 2 * padding
|
||||
val drawHeight = height - 2 * padding
|
||||
|
||||
if (isMultiChannelMode && multiChannelDataPoints.isNotEmpty()) {
|
||||
Log.d("DynamicChartView", "多通道绘制: 通道数=${multiChannelDataPoints.size}, 数据点=${multiChannelDataPoints.map { it.size }}")
|
||||
// 多通道绘制模式
|
||||
for (channelIndex in multiChannelDataPoints.indices) {
|
||||
val channelData = multiChannelDataPoints[channelIndex]
|
||||
if (channelData.isEmpty()) {
|
||||
Log.d("DynamicChartView", "通道$channelIndex 数据为空,跳过绘制")
|
||||
continue
|
||||
}
|
||||
|
||||
path.reset()
|
||||
|
||||
// 设置通道颜色
|
||||
val channelPaint = Paint(paint).apply {
|
||||
color = channelColors.getOrElse(channelIndex) { Color.GRAY }
|
||||
strokeWidth = 2f
|
||||
}
|
||||
|
||||
val xStep = if (channelData.size > 1) drawWidth / (channelData.size - 1) else drawWidth
|
||||
|
||||
for (i in channelData.indices) {
|
||||
val x = padding + i * xStep
|
||||
val normalizedValue = (channelData[i] - minValue) / (maxValue - minValue)
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, channelPaint)
|
||||
}
|
||||
} else {
|
||||
// 单通道绘制模式
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
path.reset()
|
||||
val xStep = if (dataPoints.size > 1) drawWidth / (dataPoints.size - 1) else drawWidth
|
||||
|
||||
for (i in dataPoints.indices) {
|
||||
val x = padding + i * xStep
|
||||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val desiredHeight = 200 // 每个图表的高度
|
||||
val height = resolveSize(desiredHeight, heightMeasureSpec)
|
||||
setMeasuredDimension(widthMeasureSpec, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图表数据
|
||||
*/
|
||||
fun getCurrentData(): List<Float> {
|
||||
return dataPoints.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据统计信息
|
||||
*/
|
||||
fun getDataStats(): Map<String, Any> {
|
||||
val totalDataPoints = if (isMultiChannelMode) {
|
||||
multiChannelDataPoints.sumOf { it.size }
|
||||
} else {
|
||||
dataPoints.size
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"dataPoints" to totalDataPoints,
|
||||
"multiChannelMode" to isMultiChannelMode,
|
||||
"channelCount" to if (isMultiChannelMode) multiChannelDataPoints.size else 1,
|
||||
"minValue" to minValue,
|
||||
"maxValue" to maxValue,
|
||||
"range" to (maxValue - minValue),
|
||||
"isDataAvailable" to isDataAvailable,
|
||||
"scaleFactor" to scaleFactor,
|
||||
"timeWindow" to timeWindow
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否是多通道模式
|
||||
*/
|
||||
fun isMultiChannelMode(): Boolean = isMultiChannelMode
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
color = Color.BLUE
|
||||
strokeWidth = 1.5f
|
||||
style = Paint.Style.STROKE
|
||||
isAntiAlias = true
|
||||
isAntiAlias = false // 关闭抗锯齿,提高绘制性能
|
||||
}
|
||||
|
||||
private val gridPaint = Paint().apply {
|
||||
|
|
@ -47,10 +47,7 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
private var maxValue = Float.MIN_VALUE
|
||||
private var isDataAvailable = false // 标记是否有数据
|
||||
|
||||
// 性能优化:数据缓冲和批量更新
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
private var lastUpdateTime = 0L
|
||||
private val updateInterval = 50L // 50ms更新间隔,提高流畅度
|
||||
// 简化的数据管理:直接显示新数据
|
||||
|
||||
// 缩放控制参数
|
||||
private var scaleX = 1.0f
|
||||
|
|
@ -95,49 +92,44 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
private var isDragging = false
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isNotEmpty()) {
|
||||
isDataAvailable = true
|
||||
Log.d("ECGRhythmView", "收到新数据: ${newData.size} 个点")
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 性能优化:批量更新数据
|
||||
dataBuffer.addAll(newData)
|
||||
// 更新数据范围
|
||||
updateDataRange(newData)
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
// 批量处理缓冲区的数据
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataPoints.addAll(dataBuffer)
|
||||
dataBuffer.clear()
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// 限制数据点数量
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
private fun updateDataRange(newData: List<Float>) {
|
||||
// 使用全部数据点计算范围,提供更准确的范围
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
// 计算数据范围
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
minValue = dataPoints.minOrNull() ?: 0f
|
||||
maxValue = dataPoints.maxOrNull() ?: 0f
|
||||
// 计算全部数据点的范围
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
lastUpdateTime = currentTime
|
||||
Log.d("ECGRhythmView", "更新图表,数据点: ${dataPoints.size}, 范围: $minValue - $maxValue")
|
||||
invalidate()
|
||||
}
|
||||
// 确保有足够的显示范围
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +138,26 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
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
|
||||
|
|
@ -260,7 +272,7 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'", centerX, centerY + 60f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint)
|
||||
|
||||
// 绘制一个简单的示例波形
|
||||
drawSampleWaveform(canvas, width, height)
|
||||
|
|
@ -304,9 +316,12 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
val scaledWidth = drawWidth * scaleX
|
||||
val scaledHeight = drawHeight * scaleY
|
||||
|
||||
// 性能优化:减少数据点绘制,提高流畅度
|
||||
val stepSize = maxOf(1, dataPoints.size / 1000) // 最多绘制1000个点
|
||||
val xStep = scaledWidth / (dataPoints.size - 1)
|
||||
|
||||
for (i in dataPoints.indices) {
|
||||
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%的区域显示
|
||||
|
|
@ -314,8 +329,9 @@ class ECGRhythmView @JvmOverloads constructor(
|
|||
|
||||
// 确保点在可见区域内
|
||||
if (x >= padding && x <= width - padding && y >= padding && y <= height - padding) {
|
||||
if (i == 0) {
|
||||
if (firstPoint) {
|
||||
path.moveTo(x, y)
|
||||
firstPoint = false
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,7 @@ class ECGWaveformView @JvmOverloads constructor(
|
|||
private var maxValue = Float.MIN_VALUE
|
||||
private var isDataAvailable = false // 标记是否有数据
|
||||
|
||||
// 性能优化:数据缓冲和批量更新
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
private var lastUpdateTime = 0L
|
||||
private val updateInterval = 50L // 50ms更新间隔,提高流畅度
|
||||
// 简化的数据管理:直接显示新数据
|
||||
|
||||
// 缩放控制参数
|
||||
private var scaleX = 1.0f
|
||||
|
|
@ -95,49 +92,44 @@ class ECGWaveformView @JvmOverloads constructor(
|
|||
private var isDragging = false
|
||||
|
||||
fun updateData(newData: List<Float>) {
|
||||
if (newData.isNotEmpty()) {
|
||||
isDataAvailable = true
|
||||
Log.d("ECGWaveformView", "收到新数据: ${newData.size} 个点")
|
||||
if (newData.isEmpty()) return
|
||||
|
||||
isDataAvailable = true
|
||||
|
||||
// 直接添加新数据,来多少显示多少
|
||||
dataPoints.addAll(newData)
|
||||
|
||||
// 限制数据点数量,保持滚动显示
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
|
||||
// 性能优化:批量更新数据
|
||||
dataBuffer.addAll(newData)
|
||||
// 更新数据范围
|
||||
updateDataRange(newData)
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
// 批量处理缓冲区的数据
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataPoints.addAll(dataBuffer)
|
||||
dataBuffer.clear()
|
||||
// 立即重绘
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// 限制数据点数量
|
||||
if (dataPoints.size > maxDataPoints) {
|
||||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||||
}
|
||||
private fun updateDataRange(newData: List<Float>) {
|
||||
// 使用全部数据点计算范围,提供更准确的范围
|
||||
if (dataPoints.isEmpty()) return
|
||||
|
||||
// 计算数据范围
|
||||
if (dataPoints.isNotEmpty()) {
|
||||
minValue = dataPoints.minOrNull() ?: 0f
|
||||
maxValue = dataPoints.maxOrNull() ?: 0f
|
||||
// 计算全部数据点的范围
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
lastUpdateTime = currentTime
|
||||
Log.d("ECGWaveformView", "更新图表,数据点: ${dataPoints.size}, 范围: $minValue - $maxValue")
|
||||
invalidate()
|
||||
}
|
||||
// 确保有足够的显示范围
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +138,26 @@ class ECGWaveformView @JvmOverloads constructor(
|
|||
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
|
||||
|
|
@ -260,7 +272,7 @@ class ECGWaveformView @JvmOverloads constructor(
|
|||
|
||||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'", centerX, centerY + 60f, hintPaint)
|
||||
canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint)
|
||||
|
||||
// 绘制一个简单的示例波形
|
||||
drawSampleWaveform(canvas, width, height)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,901 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.cmake_project_test.databinding.ActivityFullscreenBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import type.SensorData
|
||||
|
||||
/**
|
||||
* 全屏图表显示Activity
|
||||
* 支持ECG和PPG的不同Y轴范围设置
|
||||
*/
|
||||
class FullscreenChartActivity : AppCompatActivity(), DataManager.RealTimeDataCallback, NativeMethodCallback {
|
||||
|
||||
private lateinit var binding: ActivityFullscreenBinding
|
||||
private var fullscreenChartManager: DynamicChartManager? = null
|
||||
private var deviceType: String = "未知设备"
|
||||
private var isSingleChart = false
|
||||
private var chartIndex = 0
|
||||
|
||||
// 滤波器管理器
|
||||
private var filterManager: FullscreenFilterManager? = null
|
||||
|
||||
// Activity退出标志
|
||||
private var isExiting = false
|
||||
|
||||
// 数据回调清理标志
|
||||
private var callbackRemoved = false
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
// 静态数据管理器引用,用于接收MainActivity的数据
|
||||
private var mainDataManager: DataManager? = null
|
||||
|
||||
fun setMainDataManager(dataManager: DataManager) {
|
||||
mainDataManager = dataManager
|
||||
Log.d("FullscreenChartActivity", "设置主数据管理器引用")
|
||||
}
|
||||
|
||||
fun clearMainDataManager() {
|
||||
if (mainDataManager != null) {
|
||||
Log.d("FullscreenChartActivity", "清理主数据管理器引用")
|
||||
Log.d("FullscreenChartActivity", "注意:这只是清理引用,MainActivity的DataManager应该继续运行")
|
||||
mainDataManager = null
|
||||
} else {
|
||||
Log.d("FullscreenChartActivity", "主数据管理器引用已为空,跳过清理")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 设置全局异常处理器
|
||||
setupGlobalExceptionHandler()
|
||||
|
||||
// 设置全屏模式
|
||||
setupFullscreen()
|
||||
|
||||
binding = ActivityFullscreenBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// 初始化全屏图表管理器(避免重复初始化)
|
||||
if (fullscreenChartManager == null) {
|
||||
fullscreenChartManager = DynamicChartManager(
|
||||
context = this,
|
||||
chartsContainer = binding.fullscreenChartsContainer,
|
||||
chartTitle = null // 全屏模式下不需要标题
|
||||
)
|
||||
}
|
||||
|
||||
// 注册为数据回调接收器
|
||||
val dataManager = mainDataManager
|
||||
if (dataManager != null) {
|
||||
dataManager.addRealTimeCallback(this)
|
||||
Log.d("FullscreenChartActivity", "已注册为数据回调接收器")
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "主数据管理器为空,无法注册回调")
|
||||
}
|
||||
|
||||
// 获取传递的数据
|
||||
val chartDataArray = intent.getSerializableExtra("chartData") as? Array<Map<String, Any>>
|
||||
deviceType = intent.getStringExtra("deviceType") ?: "未知设备"
|
||||
isSingleChart = intent.getBooleanExtra("isSingleChart", false)
|
||||
chartIndex = intent.getIntExtra("chartIndex", 0)
|
||||
|
||||
if (chartDataArray != null) {
|
||||
setupFullscreenCharts(chartDataArray)
|
||||
} else {
|
||||
Log.e("FullscreenChartActivity", "没有接收到图表数据")
|
||||
finish()
|
||||
}
|
||||
|
||||
// 设置退出全屏按钮
|
||||
binding.exitFullscreenButton.setOnClickListener {
|
||||
exitFullscreen()
|
||||
}
|
||||
|
||||
// 初始化心率和血氧显示
|
||||
setupVitalSignsDisplay()
|
||||
|
||||
// 初始化滤波器管理器
|
||||
setupFilterManager()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 检查内存泄漏迹象
|
||||
*/
|
||||
private fun checkForMemoryLeaks_DISABLED(currentMemoryPercent: Double) {
|
||||
try {
|
||||
// 如果内存使用率持续高于75%且没有明显的数据积累,可能是内存泄漏
|
||||
val chartDataCount = fullscreenChartManager?.let { manager ->
|
||||
manager.getCharts().sumOf { chart -> chart.getDataPointCount() }
|
||||
} ?: 0
|
||||
|
||||
val filterCacheCount = filterManager?.let { fm ->
|
||||
// 简单估算缓存大小
|
||||
try {
|
||||
val originalCacheSize = fm.javaClass.getDeclaredField("originalDataCache")
|
||||
.apply { isAccessible = true }
|
||||
.get(fm) as Map<*, *>
|
||||
val filteredCacheSize = fm.javaClass.getDeclaredField("filteredDataCache")
|
||||
.apply { isAccessible = true }
|
||||
.get(fm) as Map<*, *>
|
||||
|
||||
originalCacheSize.size + filteredCacheSize.size
|
||||
} catch (e: Exception) {
|
||||
0
|
||||
}
|
||||
} ?: 0
|
||||
|
||||
if (currentMemoryPercent > 75.0 && chartDataCount < 5000 && filterCacheCount < 20) {
|
||||
Log.w("FullscreenChartActivity", "⚠️ 疑似内存泄漏: 内存使用率${String.format("%.1f", currentMemoryPercent)}%但数据量较少")
|
||||
Log.w("FullscreenChartActivity", "图表数据点: $chartDataCount, 滤波缓存: $filterCacheCount")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "内存泄漏检查失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行紧急内存清理
|
||||
*/
|
||||
private fun performEmergencyMemoryCleanup_DISABLED() {
|
||||
try {
|
||||
Log.d("FullscreenChartActivity", "=== 执行紧急内存清理 ===")
|
||||
|
||||
// 清理图表数据
|
||||
fullscreenChartManager?.let { chartManager ->
|
||||
val charts = chartManager.getCharts()
|
||||
var totalCleanedPoints = 0
|
||||
|
||||
charts.forEach { chart ->
|
||||
val currentPoints = chart.getDataPointCount()
|
||||
if (currentPoints > 500) { // 如果数据点超过500个,强制清理到50%
|
||||
val keepPoints = (currentPoints * 0.5).toInt()
|
||||
chart.trimDataToSize(keepPoints)
|
||||
totalCleanedPoints += (currentPoints - keepPoints)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCleanedPoints > 0) {
|
||||
Log.d("FullscreenChartActivity", "紧急清理释放了 $totalCleanedPoints 个数据点")
|
||||
}
|
||||
}
|
||||
|
||||
// 清理滤波器缓存
|
||||
filterManager?.let { fm ->
|
||||
try {
|
||||
fm.cleanupMemory()
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "清理滤波器缓存失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 强制垃圾回收
|
||||
System.gc()
|
||||
Log.d("FullscreenChartActivity", "=== 紧急内存清理完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "紧急内存清理失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 设置心率和血氧显示
|
||||
*/
|
||||
private fun setupVitalSignsDisplay() {
|
||||
// 初始化心率和血氧显示区域
|
||||
binding.heartRateValue.text = "--"
|
||||
binding.spo2Value.text = "--"
|
||||
binding.temperatureValue.text = "--"
|
||||
|
||||
// 设置标签
|
||||
binding.heartRateLabel.text = "心率 (bpm)"
|
||||
binding.spo2Label.text = "血氧 (%)"
|
||||
binding.temperatureLabel.text = "体温 (°C)"
|
||||
|
||||
Log.d("FullscreenChartActivity", "心率和血氧显示区域已初始化")
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化滤波器管理器
|
||||
*/
|
||||
private fun setupFilterManager() {
|
||||
try {
|
||||
// 避免重复初始化滤波器管理器
|
||||
if (filterManager == null) {
|
||||
filterManager = FullscreenFilterManager(
|
||||
context = this,
|
||||
scope = lifecycleScope,
|
||||
dataManager = mainDataManager,
|
||||
chartManager = fullscreenChartManager,
|
||||
deviceType = deviceType
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化UI组件
|
||||
filterManager?.initializeUI(filterBtn = binding.filterButton)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "初始化滤波器管理器失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新心率和血氧显示
|
||||
*/
|
||||
private fun updateVitalSignsDisplay(additionalData: type.SensorData.AdditionalData?) {
|
||||
if (additionalData == null) return
|
||||
|
||||
runOnUiThread {
|
||||
try {
|
||||
// 更新心率显示
|
||||
val heartRate = additionalData.hr
|
||||
if (heartRate > 0) {
|
||||
binding.heartRateValue.text = heartRate.toString()
|
||||
binding.heartRateValue.setTextColor(android.graphics.Color.GREEN)
|
||||
} else {
|
||||
binding.heartRateValue.text = "--"
|
||||
binding.heartRateValue.setTextColor(android.graphics.Color.GRAY)
|
||||
}
|
||||
|
||||
// 更新血氧显示
|
||||
val spo2 = additionalData.spo2
|
||||
if (spo2 > 0) {
|
||||
binding.spo2Value.text = spo2.toString()
|
||||
binding.spo2Value.setTextColor(android.graphics.Color.BLUE)
|
||||
} else {
|
||||
binding.spo2Value.text = "--"
|
||||
binding.spo2Value.setTextColor(android.graphics.Color.GRAY)
|
||||
}
|
||||
|
||||
// 更新体温显示
|
||||
val temperature = additionalData.temperature
|
||||
if (temperature > 0) {
|
||||
binding.temperatureValue.text = String.format("%.1f", temperature)
|
||||
binding.temperatureValue.setTextColor(android.graphics.Color.RED)
|
||||
} else {
|
||||
binding.temperatureValue.text = "--"
|
||||
binding.temperatureValue.setTextColor(android.graphics.Color.GRAY)
|
||||
}
|
||||
|
||||
Log.d("FullscreenChartActivity", "更新生命体征: HR=$heartRate, SpO2=$spo2, Temp=$temperature")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新生命体征显示失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局异常处理器
|
||||
*/
|
||||
private fun setupGlobalExceptionHandler() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
|
||||
Log.e("FullscreenChartActivity", "=== 未捕获的异常 ===")
|
||||
Log.e("FullscreenChartActivity", "线程: ${thread.name}")
|
||||
Log.e("FullscreenChartActivity", "异常类型: ${exception.javaClass.simpleName}")
|
||||
Log.e("FullscreenChartActivity", "异常消息: ${exception.message}")
|
||||
Log.e("FullscreenChartActivity", "异常堆栈: ${exception.stackTraceToString()}")
|
||||
|
||||
// 检查是否是JNI相关异常
|
||||
if (exception.message?.contains("JNI") == true ||
|
||||
exception.stackTrace.any { it.className.contains("native") || it.methodName.contains("native") }) {
|
||||
Log.e("FullscreenChartActivity", "检测到JNI相关异常,可能需要检查native代码")
|
||||
}
|
||||
|
||||
// 检查是否是内存相关异常
|
||||
if (exception is OutOfMemoryError) {
|
||||
Log.e("FullscreenChartActivity", "检测到内存不足异常,建议优化内存使用")
|
||||
}
|
||||
|
||||
// 检查是否是数组越界异常
|
||||
if (exception is IndexOutOfBoundsException) {
|
||||
Log.e("FullscreenChartActivity", "检测到数组越界异常,可能需要检查数据访问逻辑")
|
||||
}
|
||||
|
||||
// 调用默认处理器
|
||||
try {
|
||||
defaultHandler?.uncaughtException(thread, exception)
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "默认异常处理器也失败了: ${e.message}")
|
||||
// 最后手段:强制重启应用
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全屏模式
|
||||
*/
|
||||
private fun setupFullscreen() {
|
||||
// 隐藏状态栏和导航栏
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
)
|
||||
|
||||
// 保持屏幕常亮
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
// 设置全屏标题
|
||||
supportActionBar?.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全屏图表
|
||||
*/
|
||||
private fun setupFullscreenCharts(chartDataArray: Array<Map<String, Any>>) {
|
||||
Log.d("FullscreenChartActivity", "设置全屏图表,设备类型: $deviceType,图表数量: ${chartDataArray.size}")
|
||||
|
||||
// 根据设备类型创建图表
|
||||
val dataType = try {
|
||||
SensorData.DataType.valueOf(deviceType)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w("FullscreenChartActivity", "未知设备类型: $deviceType,使用默认类型")
|
||||
SensorData.DataType.PW_ECG_SL // 默认使用单导联心电
|
||||
}
|
||||
fullscreenChartManager?.createChartsForDevice(dataType)
|
||||
|
||||
// 设置全屏图表管理器的事件监听器
|
||||
fullscreenChartManager?.setOnChartFullscreenClickListener { chartIndex, chart ->
|
||||
// 在全屏模式下不需要处理全屏按钮点击
|
||||
}
|
||||
|
||||
// 设置自定义Y轴范围和全屏布局
|
||||
val charts = fullscreenChartManager?.getCharts() ?: emptyList()
|
||||
for (i in charts.indices) {
|
||||
val chart = charts[i]
|
||||
|
||||
// 设置全屏模式
|
||||
chart.setFullscreenMode(true)
|
||||
|
||||
// 设置全屏布局参数
|
||||
val layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT
|
||||
).apply {
|
||||
marginStart = 0
|
||||
marginEnd = 0
|
||||
topMargin = 0
|
||||
bottomMargin = 0
|
||||
}
|
||||
chart.layoutParams = layoutParams
|
||||
|
||||
// 根据设备类型设置Y轴范围
|
||||
when (deviceType) {
|
||||
"ECG_2LEAD",
|
||||
"ECG_12LEAD",
|
||||
"PW_ECG_SL" -> {
|
||||
// ECG设备:Y轴范围 -20 到 20
|
||||
chart.setCustomYRange(-20f, 20f)
|
||||
Log.d("FullscreenChartActivity", "设置ECG图表$i Y轴范围: -20 到 20")
|
||||
}
|
||||
"PPG" -> {
|
||||
// PPG设备:Y轴范围 -2000 到 2000
|
||||
chart.setCustomYRange(-2000f, 2000f)
|
||||
Log.d("FullscreenChartActivity", "设置PPG图表$i Y轴范围: -2000 到 2000")
|
||||
}
|
||||
else -> {
|
||||
// 其他设备:使用自动范围
|
||||
chart.clearCustomYRange()
|
||||
Log.d("FullscreenChartActivity", "设置图表$i 使用自动Y轴范围")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表数据
|
||||
val channelData = chartDataArray.map { chartData ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
chartData["data"] as List<Float>
|
||||
}
|
||||
|
||||
if (channelData.isNotEmpty()) {
|
||||
fullscreenChartManager?.updateChartsData(channelData)
|
||||
Log.d("FullscreenChartActivity", "已更新全屏图表数据,通道数: ${channelData.size}")
|
||||
}
|
||||
|
||||
// 全屏模式下不需要更新标题
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// 确保全屏模式
|
||||
setupFullscreen()
|
||||
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
Log.d("FullscreenChartActivity", "Activity暂停")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
isExiting = true
|
||||
Log.d("FullscreenChartActivity", "Activity停止")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
Log.d("FullscreenChartActivity", "开始销毁Activity")
|
||||
|
||||
// 设置退出标志,阻止所有回调
|
||||
isExiting = true
|
||||
|
||||
// 移除数据回调(防止重复移除)
|
||||
if (!callbackRemoved) {
|
||||
try {
|
||||
mainDataManager?.removeRealTimeCallback(this)
|
||||
callbackRemoved = true
|
||||
Log.d("FullscreenChartActivity", "数据回调已移除")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "移除数据回调失败: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.d("FullscreenChartActivity", "数据回调已移除,跳过重复移除")
|
||||
}
|
||||
|
||||
// 清理静态引用
|
||||
clearMainDataManager()
|
||||
|
||||
Log.d("FullscreenChartActivity", "Activity销毁完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "Activity销毁过程中出现异常: ${e.message}")
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (!isFinishing && !isDestroyed) {
|
||||
exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全退出全屏模式
|
||||
*/
|
||||
private fun exitFullscreen() {
|
||||
try {
|
||||
Log.d("FullscreenChartActivity", "开始退出全屏模式")
|
||||
|
||||
// 立即移除数据回调,防止在finish()后仍有回调执行
|
||||
if (!callbackRemoved) {
|
||||
try {
|
||||
mainDataManager?.removeRealTimeCallback(this)
|
||||
callbackRemoved = true
|
||||
Log.d("FullscreenChartActivity", "数据回调已移除")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "移除数据回调失败: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.d("FullscreenChartActivity", "数据回调已移除,跳过重复移除")
|
||||
}
|
||||
|
||||
// 设置退出标志,阻止所有回调
|
||||
isExiting = true
|
||||
|
||||
// 设置结果
|
||||
setResult(Activity.RESULT_OK)
|
||||
|
||||
// 使用更安全的方式结束Activity
|
||||
if (!isFinishing && !isDestroyed) {
|
||||
finish()
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "Activity已经在销毁过程中,跳过finish()")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "退出全屏模式失败: ${e.message}")
|
||||
// 如果出错,直接结束
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 实时数据回调实现 - 从MainActivity接收数据
|
||||
override fun onProcessedDataAvailable(channelIndex: Int, processedData: List<Float>) {
|
||||
// 检查Activity是否还在运行或正在退出
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查资源是否可用
|
||||
if (fullscreenChartManager == null || binding == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是单图表模式,只更新对应的图表
|
||||
if (isSingleChart && channelIndex != chartIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数据有效性
|
||||
if (processedData.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
runOnUiThread {
|
||||
try {
|
||||
// 再次检查Activity状态
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
Log.d("FullscreenChartActivity", "UI线程中Activity正在销毁,忽略数据回调")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
|
||||
// 检查图表管理器是否有效
|
||||
val chartManager = fullscreenChartManager
|
||||
if (chartManager == null) {
|
||||
Log.w("FullscreenChartActivity", "图表管理器为空,忽略数据回调")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 通过滤波器管理器处理数据
|
||||
val charts = chartManager.getCharts()
|
||||
if (charts.isNotEmpty()) {
|
||||
val chart = charts.getOrNull(0)
|
||||
if (chart == null) {
|
||||
Log.e("FullscreenChartActivity", "图表为空,跳过数据处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 再次检查Activity状态,防止在数据处理过程中Activity被销毁
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
Log.d("FullscreenChartActivity", "数据处理过程中Activity状态变化,停止处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查是否是PPG设备的多通道模式
|
||||
if (deviceType == "PPG" && chart.isMultiChannelMode()) {
|
||||
// PPG设备:需要获取多通道数据
|
||||
updatePPGData(chart)
|
||||
} else {
|
||||
// 单通道数据:检查是否有滤波器启用
|
||||
try {
|
||||
// 检查数据有效性
|
||||
if (processedData.isEmpty()) {
|
||||
Log.w("FullscreenChartActivity", "处理后数据为空,跳过处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查数据是否包含异常值
|
||||
val hasInvalidValues = processedData.any { it.isNaN() || it.isInfinite() }
|
||||
if (hasInvalidValues) {
|
||||
Log.w("FullscreenChartActivity", "处理后数据包含异常值,跳过处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查是否有滤波器启用
|
||||
val hasActiveFilters = filterManager?.let { manager ->
|
||||
try {
|
||||
val field = manager.javaClass.getDeclaredField("isAnyFilterEnabled")
|
||||
field.isAccessible = true
|
||||
field.getBoolean(manager)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
|
||||
if (hasActiveFilters) {
|
||||
// 有滤波器启用时,使用滤波器处理
|
||||
filterManager?.processRealTimeData("channel_$channelIndex", processedData) { filteredData ->
|
||||
try {
|
||||
// 再次检查Activity状态
|
||||
if (isFinishing || isDestroyed) {
|
||||
Log.d("FullscreenChartActivity", "Activity正在销毁,跳过图表更新")
|
||||
return@processRealTimeData
|
||||
}
|
||||
|
||||
// 检查滤波后数据有效性
|
||||
if (filteredData.isEmpty()) {
|
||||
Log.w("FullscreenChartActivity", "滤波后数据为空,跳过更新")
|
||||
return@processRealTimeData
|
||||
}
|
||||
|
||||
chart.updateData(filteredData)
|
||||
Log.d("FullscreenChartActivity", "已更新滤波后的单通道数据")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新单通道数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有滤波器启用时,直接更新图表
|
||||
chart.updateData(processedData)
|
||||
Log.d("FullscreenChartActivity", "已直接更新处理后数据(无滤波)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "数据处理失败: ${e.message}", e)
|
||||
// 出错时直接使用原始数据
|
||||
try {
|
||||
if (!isFinishing && !isDestroyed && processedData.isNotEmpty()) {
|
||||
chart.updateData(processedData)
|
||||
Log.d("FullscreenChartActivity", "已更新原始单通道数据")
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新原始数据也失败: ${e2.message}", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "处理实时数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRawDataAvailable(channelIndex: Int, rawData: List<Float>) {
|
||||
// 检查Activity是否还在运行或正在退出
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查资源是否可用
|
||||
if (fullscreenChartManager == null || binding == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是单图表模式,只更新对应的图表
|
||||
if (isSingleChart && channelIndex != chartIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数据有效性
|
||||
if (rawData.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
runOnUiThread {
|
||||
try {
|
||||
// 再次检查Activity状态和退出标志
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
Log.d("FullscreenChartActivity", "UI线程中Activity正在销毁,忽略原始数据回调")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查资源是否仍然可用
|
||||
if (fullscreenChartManager == null || binding == null) {
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
|
||||
// 检查图表管理器是否有效
|
||||
val chartManager = fullscreenChartManager
|
||||
if (chartManager == null) {
|
||||
Log.w("FullscreenChartActivity", "图表管理器为空,忽略原始数据回调")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 通过滤波器管理器处理数据
|
||||
val charts = chartManager.getCharts()
|
||||
if (charts.isNotEmpty()) {
|
||||
val chart = charts.getOrNull(channelIndex.coerceAtMost(charts.size - 1))
|
||||
if (chart == null) {
|
||||
Log.e("FullscreenChartActivity", "通道${channelIndex}对应的图表为空,跳过数据处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 再次检查Activity状态,防止在数据处理过程中Activity被销毁
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
Log.d("FullscreenChartActivity", "原始数据处理过程中Activity状态变化,停止处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查是否是PPG设备的多通道模式
|
||||
if (deviceType == "PPG" && chart.isMultiChannelMode()) {
|
||||
// PPG设备:需要获取多通道数据
|
||||
updatePPGData(chart)
|
||||
} else {
|
||||
// 单通道数据:检查是否有滤波器启用
|
||||
try {
|
||||
// 检查数据有效性
|
||||
if (rawData.isEmpty()) {
|
||||
Log.w("FullscreenChartActivity", "原始数据为空,跳过处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查数据是否包含异常值
|
||||
val hasInvalidValues = rawData.any { it.isNaN() || it.isInfinite() }
|
||||
if (hasInvalidValues) {
|
||||
Log.w("FullscreenChartActivity", "原始数据包含异常值,跳过处理")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
// 检查是否有滤波器启用
|
||||
val hasActiveFilters = filterManager?.let { manager ->
|
||||
// 通过反射或其他方式检查滤波器状态
|
||||
try {
|
||||
val field = manager.javaClass.getDeclaredField("isAnyFilterEnabled")
|
||||
field.isAccessible = true
|
||||
field.getBoolean(manager)
|
||||
} catch (e: Exception) {
|
||||
false // 如果无法检查,假设没有启用
|
||||
}
|
||||
} ?: false
|
||||
|
||||
if (hasActiveFilters) {
|
||||
// 有滤波器启用时,使用滤波器处理
|
||||
filterManager?.processRealTimeData("channel_$channelIndex", rawData) { filteredData ->
|
||||
try {
|
||||
// 再次检查Activity状态
|
||||
if (isFinishing || isDestroyed) {
|
||||
Log.d("FullscreenChartActivity", "Activity正在销毁,跳过图表更新")
|
||||
return@processRealTimeData
|
||||
}
|
||||
|
||||
// 检查滤波后数据有效性
|
||||
if (filteredData.isEmpty()) {
|
||||
Log.w("FullscreenChartActivity", "滤波后数据为空,跳过更新")
|
||||
return@processRealTimeData
|
||||
}
|
||||
|
||||
chart.updateData(filteredData)
|
||||
Log.d("FullscreenChartActivity", "已更新滤波后的原始数据")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新原始数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有滤波器启用时,直接更新图表
|
||||
chart.updateData(rawData)
|
||||
Log.d("FullscreenChartActivity", "已直接更新原始数据(无滤波)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "数据处理失败: ${e.message}", e)
|
||||
// 出错时直接使用原始数据
|
||||
try {
|
||||
if (!isFinishing && !isDestroyed && rawData.isNotEmpty()) {
|
||||
chart.updateData(rawData)
|
||||
Log.d("FullscreenChartActivity", "已更新原始数据")
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新原始数据也失败: ${e2.message}", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从DataManager获取最新的AdditionalData并更新生命体征显示
|
||||
updateVitalSignsFromDataManager()
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "处理原始数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从DataManager获取最新的生命体征数据并更新显示
|
||||
*/
|
||||
private fun updateVitalSignsFromDataManager() {
|
||||
// 检查Activity状态
|
||||
if (isFinishing || isDestroyed) {
|
||||
Log.d("FullscreenChartActivity", "Activity正在销毁,跳过生命体征更新")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val dataManager = mainDataManager
|
||||
if (dataManager != null) {
|
||||
// 获取最新的数据包
|
||||
val latestPackets = dataManager.getPacketBuffer()
|
||||
if (latestPackets.isNotEmpty()) {
|
||||
// 获取最新的数据包中的AdditionalData
|
||||
val latestPacket = latestPackets.last()
|
||||
val additionalData = latestPacket.additionalData
|
||||
|
||||
if (additionalData != null) {
|
||||
updateVitalSignsDisplay(additionalData)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "获取生命体征数据失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新PPG多通道数据
|
||||
*/
|
||||
private fun updatePPGData(chart: DynamicChartView) {
|
||||
try {
|
||||
// 检查Activity状态
|
||||
if (isFinishing || isDestroyed) {
|
||||
Log.d("FullscreenChartActivity", "Activity正在销毁,跳过PPG数据更新")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查图表是否有效
|
||||
if (chart == null) {
|
||||
Log.w("FullscreenChartActivity", "图表为空,跳过PPG数据更新")
|
||||
return
|
||||
}
|
||||
|
||||
// 从主数据管理器获取PPG数据
|
||||
val dataManager = mainDataManager
|
||||
if (dataManager != null) {
|
||||
try {
|
||||
val mappedPackets = dataManager.getMappedPacketBuffer()
|
||||
if (mappedPackets.isNotEmpty()) {
|
||||
val latestMappedPacket = mappedPackets.last()
|
||||
val channelData = latestMappedPacket.getChannelData()
|
||||
if (channelData != null && channelData.isNotEmpty()) {
|
||||
// 验证数据格式
|
||||
if (channelData.size >= 2 && channelData[0].isNotEmpty() && channelData[1].isNotEmpty()) {
|
||||
chart.updateMultiChannelData(channelData)
|
||||
Log.d("FullscreenChartActivity", "更新PPG多通道数据: ${channelData.size}通道")
|
||||
return
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "PPG数据格式不正确: ${channelData.map { it.size }}")
|
||||
}
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "PPG通道数据为空")
|
||||
}
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "映射数据包为空")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "获取PPG数据失败: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
Log.w("FullscreenChartActivity", "数据管理器为空")
|
||||
}
|
||||
|
||||
// 如果无法获取数据,使用模拟数据
|
||||
try {
|
||||
val mockPPGData = listOf(
|
||||
listOf(100f, 120f, 110f, 130f, 115f), // 红光PPG
|
||||
listOf(200f, 220f, 210f, 230f, 215f) // 红外光PPG
|
||||
)
|
||||
chart.updateMultiChannelData(mockPPGData)
|
||||
Log.d("FullscreenChartActivity", "使用模拟PPG数据")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新模拟PPG数据失败: ${e.message}", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenChartActivity", "更新PPG数据失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStreamingProgress(progress: Int, totalSamples: Int, processedSamples: Int) {
|
||||
// 检查Activity是否还在运行或正在退出
|
||||
if (isFinishing || isDestroyed || isExiting) {
|
||||
return
|
||||
}
|
||||
// 全屏模式下不需要显示进度
|
||||
}
|
||||
|
||||
// NativeMethodCallback实现
|
||||
override external fun createStreamParser(): Long
|
||||
override external fun destroyStreamParser(handle: Long)
|
||||
override external fun streamParserAppend(handle: Long, chunk: ByteArray)
|
||||
override external fun streamParserDrainPackets(handle: Long): List<SensorData>?
|
||||
}
|
||||
|
|
@ -0,0 +1,847 @@
|
|||
package com.example.cmake_project_test
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.Switch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 全屏模式滤波器管理器
|
||||
* 管理滤波器状态、UI交互和数据处理
|
||||
*/
|
||||
class FullscreenFilterManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val dataManager: DataManager?,
|
||||
private val chartManager: DynamicChartManager?,
|
||||
private val deviceType: String = "PW_ECG_SL" // 默认设备类型
|
||||
) {
|
||||
|
||||
// 滤波器状态数据类
|
||||
data class FilterState(
|
||||
var enabled: Boolean = false,
|
||||
var frequency: Double = 0.0
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据设备类型获取采样率
|
||||
*/
|
||||
private fun getSampleRate(): Double {
|
||||
return when (deviceType) {
|
||||
"ECG_2LEAD", "ECG_12LEAD", "PW_ECG_SL", "EEG" -> 250.0
|
||||
"PPG" -> 50.0
|
||||
"STETHOSCOPE", "SNORE" -> 8000.0
|
||||
"RESPIRATION" -> 50.0
|
||||
else -> 250.0 // 默认采样率
|
||||
}
|
||||
}
|
||||
|
||||
// 当前滤波器状态
|
||||
private val lowpassFilter = FilterState(false, 40.0)
|
||||
private val highpassFilter = FilterState(false, 1.0)
|
||||
private val notchFilter = FilterState(false, 50.0)
|
||||
|
||||
// 是否有任何滤波器启用
|
||||
private var isAnyFilterEnabled = false
|
||||
|
||||
// 内存管理:动态调整的缓冲区大小
|
||||
private var maxDataPointsPerBatch = 100 // 每次最多处理100个数据点
|
||||
private val dataBuffer = mutableListOf<Float>()
|
||||
private var maxBufferSize = 500 // 缓冲区最大大小(动态调整)
|
||||
|
||||
// UI组件引用
|
||||
private var filterButton: Button? = null
|
||||
private var currentDialog: android.app.AlertDialog? = null
|
||||
|
||||
// 原始数据和滤波后数据缓存 - 改进为带时间戳的缓存
|
||||
private val originalDataCache = mutableMapOf<String, CacheEntry>()
|
||||
private val filteredDataCache = mutableMapOf<String, CacheEntry>()
|
||||
|
||||
// 缓存配置
|
||||
private val maxCacheEntries = 50 // 最大缓存条目数
|
||||
private val maxCacheDataSize = 10000 // 每个缓存条目的最大数据点数
|
||||
private val cacheExpirationTime = 5 * 60 * 1000L // 缓存过期时间:5分钟
|
||||
|
||||
// 缓存条目类
|
||||
data class CacheEntry(
|
||||
val data: MutableList<Float>,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
var accessCount: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* 初始化UI组件
|
||||
*/
|
||||
fun initializeUI(filterBtn: Button) {
|
||||
filterButton = filterBtn
|
||||
|
||||
// 根据设备配置动态调整缓冲区大小
|
||||
optimizeBufferSizes()
|
||||
|
||||
setupUIListeners()
|
||||
updateUIState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备配置和内存情况动态优化缓冲区大小
|
||||
*/
|
||||
private fun optimizeBufferSizes() {
|
||||
try {
|
||||
val runtime = Runtime.getRuntime()
|
||||
val maxMemoryMB = runtime.maxMemory() / 1024 / 1024
|
||||
|
||||
// 根据设备采样率调整批处理大小
|
||||
val sampleRate = getSampleRate()
|
||||
when {
|
||||
sampleRate >= 8000.0 -> { // 音频设备
|
||||
maxDataPointsPerBatch = 50
|
||||
maxBufferSize = 200
|
||||
}
|
||||
sampleRate >= 1000.0 -> { // 高速设备
|
||||
maxDataPointsPerBatch = 100
|
||||
maxBufferSize = 400
|
||||
}
|
||||
sampleRate >= 250.0 -> { // 中速设备(ECG等)
|
||||
maxDataPointsPerBatch = 150
|
||||
maxBufferSize = 600
|
||||
}
|
||||
else -> { // 低速设备
|
||||
maxDataPointsPerBatch = 200
|
||||
maxBufferSize = 800
|
||||
}
|
||||
}
|
||||
|
||||
// 根据可用内存进一步调整
|
||||
when {
|
||||
maxMemoryMB < 128 -> { // 低内存设备
|
||||
maxDataPointsPerBatch = (maxDataPointsPerBatch * 0.5).toInt()
|
||||
maxBufferSize = (maxBufferSize * 0.5).toInt()
|
||||
}
|
||||
maxMemoryMB < 256 -> { // 中等内存设备
|
||||
maxDataPointsPerBatch = (maxDataPointsPerBatch * 0.75).toInt()
|
||||
maxBufferSize = (maxBufferSize * 0.75).toInt()
|
||||
}
|
||||
maxMemoryMB >= 512 -> { // 高内存设备
|
||||
maxDataPointsPerBatch = (maxDataPointsPerBatch * 1.5).toInt()
|
||||
maxBufferSize = (maxBufferSize * 1.5).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
// 确保最小值
|
||||
maxDataPointsPerBatch = maxOf(maxDataPointsPerBatch, 25)
|
||||
maxBufferSize = maxOf(maxBufferSize, 100)
|
||||
|
||||
Log.d("FullscreenFilterManager", "缓冲区大小优化完成: 批处理=$maxDataPointsPerBatch, 缓冲区=$maxBufferSize, 内存=${maxMemoryMB}MB")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "缓冲区大小优化失败: ${e.message}")
|
||||
// 使用默认值
|
||||
maxDataPointsPerBatch = 100
|
||||
maxBufferSize = 500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置UI监听器
|
||||
*/
|
||||
private fun setupUIListeners() {
|
||||
// 滤波器按键
|
||||
filterButton?.setOnClickListener {
|
||||
showFilterDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示滤波器设置对话框
|
||||
*/
|
||||
private fun showFilterDialog() {
|
||||
try {
|
||||
// Context在构造函数中传入,不会为null,但为了安全起见保留检查
|
||||
|
||||
val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_filter_settings, null)
|
||||
|
||||
// 获取对话框中的组件
|
||||
val lowpassSwitch = dialogView.findViewById<Switch>(R.id.lowpass_enable_switch)
|
||||
val highpassSwitch = dialogView.findViewById<Switch>(R.id.highpass_enable_switch)
|
||||
val notchSwitch = dialogView.findViewById<Switch>(R.id.notch_enable_switch)
|
||||
val lowpassFreqInput = dialogView.findViewById<EditText>(R.id.lowpass_frequency_input)
|
||||
val highpassFreqInput = dialogView.findViewById<EditText>(R.id.highpass_frequency_input)
|
||||
val notchFreqInput = dialogView.findViewById<EditText>(R.id.notch_frequency_input)
|
||||
val btnCancel = dialogView.findViewById<Button>(R.id.btn_cancel)
|
||||
val btnApply = dialogView.findViewById<Button>(R.id.btn_apply)
|
||||
|
||||
// 检查组件是否获取成功
|
||||
if (lowpassSwitch == null || highpassSwitch == null || notchSwitch == null ||
|
||||
lowpassFreqInput == null || highpassFreqInput == null || notchFreqInput == null ||
|
||||
btnCancel == null || btnApply == null) {
|
||||
Log.e("FullscreenFilterManager", "对话框组件获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置当前状态
|
||||
setupDialogCurrentState(lowpassSwitch, highpassSwitch, notchSwitch,
|
||||
lowpassFreqInput, highpassFreqInput, notchFreqInput)
|
||||
|
||||
// 创建对话框
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
|
||||
// 保存对话框引用
|
||||
currentDialog = dialog
|
||||
|
||||
// 设置按钮监听器
|
||||
btnCancel.setOnClickListener {
|
||||
try {
|
||||
dialog.dismiss()
|
||||
currentDialog = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "关闭对话框失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
btnApply.setOnClickListener {
|
||||
try {
|
||||
applyFilterFromDialog(lowpassSwitch, highpassSwitch, notchSwitch,
|
||||
lowpassFreqInput, highpassFreqInput, notchFreqInput)
|
||||
dialog.dismiss()
|
||||
currentDialog = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "应用滤波器设置失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 设置对话框取消监听器
|
||||
dialog.setOnCancelListener {
|
||||
Log.d("FullscreenFilterManager", "对话框被取消")
|
||||
currentDialog = null
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "显示滤波器对话框失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置对话框的当前状态
|
||||
*/
|
||||
private fun setupDialogCurrentState(
|
||||
lowpassSwitch: Switch,
|
||||
highpassSwitch: Switch,
|
||||
notchSwitch: Switch,
|
||||
lowpassFreqInput: EditText,
|
||||
highpassFreqInput: EditText,
|
||||
notchFreqInput: EditText
|
||||
) {
|
||||
// 设置开关状态
|
||||
lowpassSwitch.isChecked = lowpassFilter.enabled
|
||||
highpassSwitch.isChecked = highpassFilter.enabled
|
||||
notchSwitch.isChecked = notchFilter.enabled
|
||||
|
||||
// 设置频率值
|
||||
lowpassFreqInput.setText(lowpassFilter.frequency.toInt().toString())
|
||||
highpassFreqInput.setText(highpassFilter.frequency.toInt().toString())
|
||||
notchFreqInput.setText(notchFilter.frequency.toInt().toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对话框应用滤波器设置
|
||||
*/
|
||||
private fun applyFilterFromDialog(
|
||||
lowpassSwitch: Switch,
|
||||
highpassSwitch: Switch,
|
||||
notchSwitch: Switch,
|
||||
lowpassFreqInput: EditText,
|
||||
highpassFreqInput: EditText,
|
||||
notchFreqInput: EditText
|
||||
) {
|
||||
try {
|
||||
// 获取开关状态
|
||||
val lowpassEnabled = lowpassSwitch.isChecked
|
||||
val highpassEnabled = highpassSwitch.isChecked
|
||||
val notchEnabled = notchSwitch.isChecked
|
||||
|
||||
// 获取频率值并验证
|
||||
val lowpassFreq = parseFrequency(lowpassFreqInput.text.toString(), 40.0)
|
||||
val highpassFreq = parseFrequency(highpassFreqInput.text.toString(), 1.0)
|
||||
val notchFreq = parseFrequency(notchFreqInput.text.toString(), 50.0)
|
||||
|
||||
// 更新滤波器状态
|
||||
lowpassFilter.enabled = lowpassEnabled
|
||||
lowpassFilter.frequency = lowpassFreq
|
||||
highpassFilter.enabled = highpassEnabled
|
||||
highpassFilter.frequency = highpassFreq
|
||||
notchFilter.enabled = notchEnabled
|
||||
notchFilter.frequency = notchFreq
|
||||
|
||||
// 检查是否有任何滤波器启用
|
||||
isAnyFilterEnabled = lowpassEnabled || highpassEnabled || notchEnabled
|
||||
|
||||
// 应用滤波器
|
||||
applyCurrentFilter()
|
||||
updateUIState()
|
||||
|
||||
Log.d("FullscreenFilterManager", "滤波器设置已应用: 低通=${lowpassEnabled}(${lowpassFreq}Hz), 高通=${highpassEnabled}(${highpassFreq}Hz), 陷波=${notchEnabled}(${notchFreq}Hz)")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "应用滤波器设置失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析频率值
|
||||
*/
|
||||
private fun parseFrequency(freqText: String, defaultValue: Double): Double {
|
||||
return try {
|
||||
val freq = freqText.toDouble()
|
||||
if (freq > 0 && freq <= 1000) freq else defaultValue
|
||||
} catch (e: Exception) {
|
||||
Log.w("FullscreenFilterManager", "频率解析失败: $freqText, 使用默认值: $defaultValue")
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新UI状态
|
||||
*/
|
||||
private fun updateUIState() {
|
||||
// 更新滤波器按键状态
|
||||
if (isAnyFilterEnabled) {
|
||||
filterButton?.setBackgroundColor(0xFF4CAF50.toInt())
|
||||
val enabledFilters = mutableListOf<String>()
|
||||
if (lowpassFilter.enabled) enabledFilters.add("低通")
|
||||
if (highpassFilter.enabled) enabledFilters.add("高通")
|
||||
if (notchFilter.enabled) enabledFilters.add("陷波")
|
||||
filterButton?.text = "滤波器 ✓ (${enabledFilters.joinToString(",")})"
|
||||
} else {
|
||||
filterButton?.setBackgroundColor(0xFF666666.toInt())
|
||||
filterButton?.text = "滤波器"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用当前滤波器
|
||||
*/
|
||||
private fun applyCurrentFilter() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
if (isAnyFilterEnabled) {
|
||||
// 应用滤波器
|
||||
dataManager?.setFilterSettings(createFilterSettings())
|
||||
Log.d("FullscreenFilterManager", "应用叠加滤波器: 低通=${lowpassFilter.enabled}, 高通=${highpassFilter.enabled}, 陷波=${notchFilter.enabled}")
|
||||
} else {
|
||||
// 关闭所有滤波器
|
||||
dataManager?.setFilterSettings(FilterSettings())
|
||||
Log.d("FullscreenFilterManager", "关闭所有滤波器")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "应用滤波器失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前状态创建滤波器设置
|
||||
*/
|
||||
private fun createFilterSettings(): FilterSettings {
|
||||
return FilterSettings(
|
||||
sampleRate = 1000.0,
|
||||
// 根据启用的滤波器设置参数
|
||||
generalLowPass = if (lowpassFilter.enabled) lowpassFilter.frequency else 100.0,
|
||||
generalHighPass = if (highpassFilter.enabled) highpassFilter.frequency else 0.1,
|
||||
generalNotchFreq = if (notchFilter.enabled) notchFilter.frequency else 50.0,
|
||||
generalNotchQ = if (notchFilter.enabled) 30.0 else 30.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理实时数据并返回滤波后的数据(优化内存管理)
|
||||
*/
|
||||
fun processRealTimeData(dataType: String, data: List<Float>, callback: (List<Float>) -> Unit) {
|
||||
try {
|
||||
// 检查数据有效性
|
||||
if (data.isEmpty()) {
|
||||
Log.w("FullscreenFilterManager", "数据为空,跳过处理")
|
||||
callback(emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数据是否包含异常值
|
||||
val hasInvalidValues = data.any { it.isNaN() || it.isInfinite() }
|
||||
if (hasInvalidValues) {
|
||||
Log.w("FullscreenFilterManager", "数据包含异常值,使用原始数据")
|
||||
callback(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAnyFilterEnabled) {
|
||||
// 无滤波时直接使用原始数据,不进行任何处理
|
||||
callback(data)
|
||||
return
|
||||
}
|
||||
|
||||
// 内存管理:限制单次处理的数据量(仅在滤波时)
|
||||
val dataToProcess = if (data.size > maxDataPointsPerBatch) {
|
||||
data.takeLast(maxDataPointsPerBatch) // 只处理最新的数据点
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
// 应用滤波器处理数据
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
// 定期清理内存
|
||||
cleanupMemory()
|
||||
|
||||
val filteredData = applyOverlayFiltersToData(dataToProcess)
|
||||
scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
callback(filteredData)
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "回调执行失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "数据处理失败: ${e.message}", e)
|
||||
// 出错时使用原始数据
|
||||
scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
callback(dataToProcess)
|
||||
} catch (e2: Exception) {
|
||||
Log.e("FullscreenFilterManager", "回调执行也失败: ${e2.message}", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "processRealTimeData异常: ${e.message}", e)
|
||||
try {
|
||||
callback(data)
|
||||
} catch (e2: Exception) {
|
||||
Log.e("FullscreenFilterManager", "最终回调也失败: ${e2.message}", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理实时数据(兼容旧接口)
|
||||
*/
|
||||
fun processRealTimeData(dataType: String, data: List<Float>) {
|
||||
processRealTimeData(dataType, data) { filteredData ->
|
||||
updateChartWithData(filteredData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图表数据
|
||||
*/
|
||||
private fun updateChartWithData(data: List<Float>) {
|
||||
try {
|
||||
val charts = chartManager?.getCharts()
|
||||
if (charts != null && charts.isNotEmpty()) {
|
||||
val chart = charts[0]
|
||||
chart.updateData(data)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "更新图表数据失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对数据进行叠加滤波处理
|
||||
*/
|
||||
private fun applyOverlayFiltersToData(data: List<Float>): List<Float> {
|
||||
if (data.isEmpty()) return data
|
||||
|
||||
try {
|
||||
var result = data
|
||||
|
||||
// 按顺序应用启用的滤波器
|
||||
// 1. 高通滤波(去除基线漂移)
|
||||
if (highpassFilter.enabled && highpassFilter.frequency > 0) {
|
||||
try {
|
||||
result = applyHighPassFilter(result, highpassFilter.frequency)
|
||||
Log.d("FullscreenFilterManager", "高通滤波完成: ${result.size}个数据点")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "高通滤波失败: ${e.message}", e)
|
||||
// 高通滤波失败时跳过,继续处理其他滤波器
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 低通滤波(去除高频噪声)
|
||||
if (lowpassFilter.enabled && lowpassFilter.frequency > 0) {
|
||||
try {
|
||||
result = applyLowPassFilter(result, lowpassFilter.frequency)
|
||||
Log.d("FullscreenFilterManager", "低通滤波完成: ${result.size}个数据点")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "低通滤波失败: ${e.message}", e)
|
||||
// 低通滤波失败时跳过,继续处理其他滤波器
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 陷波滤波(去除工频干扰)
|
||||
if (notchFilter.enabled && notchFilter.frequency > 0) {
|
||||
try {
|
||||
result = applyNotchFilter(result, notchFilter.frequency)
|
||||
Log.d("FullscreenFilterManager", "陷波滤波完成: ${result.size}个数据点")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "陷波滤波失败: ${e.message}", e)
|
||||
// 陷波滤波失败时跳过
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查:确保结果数据有效
|
||||
if (result.isEmpty()) {
|
||||
Log.w("FullscreenFilterManager", "滤波后数据为空,返回原始数据")
|
||||
return data
|
||||
}
|
||||
|
||||
// 检查是否有异常值
|
||||
val hasInvalidValues = result.any { it.isNaN() || it.isInfinite() }
|
||||
if (hasInvalidValues) {
|
||||
Log.w("FullscreenFilterManager", "滤波后数据包含异常值,返回原始数据")
|
||||
return data
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "滤波处理异常: ${e.message}", e)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 低通滤波(简单移动平均 - 更稳定的算法)
|
||||
*/
|
||||
private fun applyLowPassFilter(data: List<Float>, cutoffFreq: Double): List<Float> {
|
||||
if (data.isEmpty()) return data
|
||||
|
||||
val result = mutableListOf<Float>()
|
||||
val sampleRate = getSampleRate()
|
||||
|
||||
// 计算窗口大小,限制在合理范围内
|
||||
val windowSize = (sampleRate / cutoffFreq).toInt().coerceIn(3, 15)
|
||||
|
||||
// 使用简单的移动平均,避免复杂的边界处理
|
||||
for (i in data.indices) {
|
||||
val start = maxOf(0, i - windowSize / 2)
|
||||
val end = minOf(data.size, i + windowSize / 2 + 1)
|
||||
|
||||
var sum = 0f
|
||||
var count = 0
|
||||
|
||||
for (j in start until end) {
|
||||
val value = data[j]
|
||||
if (!value.isNaN() && !value.isInfinite()) {
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
val average = if (count > 0) sum / count else data[i]
|
||||
result.add(average.coerceIn(-1000f, 1000f))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 高通滤波(简单差分滤波 - 更稳定的算法)
|
||||
*/
|
||||
private fun applyHighPassFilter(data: List<Float>, cutoffFreq: Double): List<Float> {
|
||||
if (data.isEmpty()) return data
|
||||
|
||||
val result = mutableListOf<Float>()
|
||||
|
||||
// 使用简单的差分滤波,去除基线漂移
|
||||
// 计算移动平均作为基线
|
||||
val sampleRate = getSampleRate()
|
||||
val baselineWindow = (sampleRate / cutoffFreq).toInt().coerceIn(5, 25)
|
||||
|
||||
for (i in data.indices) {
|
||||
val start = maxOf(0, i - baselineWindow / 2)
|
||||
val end = minOf(data.size, i + baselineWindow / 2 + 1)
|
||||
|
||||
var sum = 0f
|
||||
var count = 0
|
||||
|
||||
for (j in start until end) {
|
||||
val value = data[j]
|
||||
if (!value.isNaN() && !value.isInfinite()) {
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
val baseline = if (count > 0) sum / count else 0f
|
||||
val highPassValue = data[i] - baseline
|
||||
|
||||
result.add(highPassValue.coerceIn(-1000f, 1000f))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 陷波滤波(简单陷波滤波 - 更稳定的算法)
|
||||
*/
|
||||
private fun applyNotchFilter(data: List<Float>, notchFreq: Double): List<Float> {
|
||||
if (data.isEmpty()) return data
|
||||
|
||||
val result = mutableListOf<Float>()
|
||||
val sampleRate = getSampleRate()
|
||||
|
||||
// 使用简单的陷波滤波:检测特定频率的周期性信号并去除
|
||||
val period = (sampleRate / notchFreq).toInt().coerceIn(2, 20)
|
||||
|
||||
for (i in data.indices) {
|
||||
val currentValue = data[i]
|
||||
|
||||
// 计算周围几个周期的平均值
|
||||
var sum = 0f
|
||||
var count = 0
|
||||
|
||||
for (offset in -period..period) {
|
||||
val index = i + offset
|
||||
if (index >= 0 && index < data.size) {
|
||||
val value = data[index]
|
||||
if (!value.isNaN() && !value.isInfinite()) {
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val average = if (count > 0) sum / count else currentValue
|
||||
|
||||
// 简单的陷波:如果当前值与平均值差异很大,则使用平均值
|
||||
val diff = kotlin.math.abs(currentValue - average)
|
||||
val threshold = kotlin.math.abs(average) * 0.1f // 10%的阈值
|
||||
|
||||
val filteredValue = if (diff > threshold) {
|
||||
average * 0.8f + currentValue * 0.2f // 混合处理
|
||||
} else {
|
||||
currentValue
|
||||
}
|
||||
|
||||
result.add(filteredValue.coerceIn(-1000f, 1000f))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前滤波器状态信息
|
||||
*/
|
||||
fun getFilterStatus(): String {
|
||||
return if (isAnyFilterEnabled) {
|
||||
val enabledFilters = mutableListOf<String>()
|
||||
if (lowpassFilter.enabled) enabledFilters.add("低通(${lowpassFilter.frequency.toInt()}Hz)")
|
||||
if (highpassFilter.enabled) enabledFilters.add("高通(${highpassFilter.frequency.toInt()}Hz)")
|
||||
if (notchFilter.enabled) enabledFilters.add("陷波(${notchFilter.frequency.toInt()}Hz)")
|
||||
enabledFilters.joinToString(", ")
|
||||
} else {
|
||||
"无滤波"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
Log.d("FullscreenFilterManager", "=== cleanup 开始 ===")
|
||||
|
||||
// 清理对话框
|
||||
try {
|
||||
Log.d("FullscreenFilterManager", "开始清理对话框")
|
||||
if (currentDialog != null) {
|
||||
currentDialog?.dismiss()
|
||||
currentDialog = null
|
||||
Log.d("FullscreenFilterManager", "对话框清理完成")
|
||||
} else {
|
||||
Log.d("FullscreenFilterManager", "对话框为空,无需清理")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "关闭对话框失败: ${e.message}", e)
|
||||
Log.e("FullscreenFilterManager", "对话框异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
|
||||
// 清理UI组件引用
|
||||
Log.d("FullscreenFilterManager", "清理UI组件引用")
|
||||
filterButton = null
|
||||
|
||||
// 清理数据缓存
|
||||
Log.d("FullscreenFilterManager", "清理数据缓存")
|
||||
try {
|
||||
originalDataCache.clear()
|
||||
Log.d("FullscreenFilterManager", "原始数据缓存清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "清理原始数据缓存失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
try {
|
||||
filteredDataCache.clear()
|
||||
Log.d("FullscreenFilterManager", "滤波数据缓存清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "清理滤波数据缓存失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
try {
|
||||
dataBuffer.clear()
|
||||
Log.d("FullscreenFilterManager", "数据缓冲区清理完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "清理数据缓冲区失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 注意:scope是lifecycleScope,会自动管理协程生命周期
|
||||
Log.d("FullscreenFilterManager", "=== cleanup 完成 ===")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "清理资源时发生异常: ${e.message}", e)
|
||||
Log.e("FullscreenFilterManager", "异常堆栈: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加原始数据到缓存
|
||||
*/
|
||||
private fun addToOriginalCache(key: String, data: List<Float>) {
|
||||
try {
|
||||
// 限制数据大小
|
||||
val limitedData = if (data.size > maxCacheDataSize) {
|
||||
data.takeLast(maxCacheDataSize).toMutableList()
|
||||
} else {
|
||||
data.toMutableList()
|
||||
}
|
||||
|
||||
originalDataCache[key] = CacheEntry(limitedData)
|
||||
cleanupCacheIfNeeded(originalDataCache, "原始数据")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "添加原始数据缓存失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加滤波数据到缓存
|
||||
*/
|
||||
private fun addToFilteredCache(key: String, data: List<Float>) {
|
||||
try {
|
||||
// 限制数据大小
|
||||
val limitedData = if (data.size > maxCacheDataSize) {
|
||||
data.takeLast(maxCacheDataSize).toMutableList()
|
||||
} else {
|
||||
data.toMutableList()
|
||||
}
|
||||
|
||||
filteredDataCache[key] = CacheEntry(limitedData)
|
||||
cleanupCacheIfNeeded(filteredDataCache, "滤波数据")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "添加滤波数据缓存失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始数据缓存获取数据
|
||||
*/
|
||||
private fun getFromOriginalCache(key: String): List<Float>? {
|
||||
val entry = originalDataCache[key]
|
||||
return entry?.let {
|
||||
it.accessCount++
|
||||
it.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从滤波数据缓存获取数据
|
||||
*/
|
||||
private fun getFromFilteredCache(key: String): List<Float>? {
|
||||
val entry = filteredDataCache[key]
|
||||
return entry?.let {
|
||||
it.accessCount++
|
||||
it.data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存(如果需要)
|
||||
*/
|
||||
private fun cleanupCacheIfNeeded(cache: MutableMap<String, CacheEntry>, cacheName: String) {
|
||||
try {
|
||||
// 检查条目数量
|
||||
if (cache.size > maxCacheEntries) {
|
||||
// 按访问频率和时间排序,保留最常用的
|
||||
val sortedEntries = cache.entries.sortedWith(compareBy(
|
||||
{ -it.value.accessCount }, // 访问次数多的优先保留
|
||||
{ -it.value.timestamp } // 最近访问的优先保留
|
||||
))
|
||||
|
||||
// 保留前80%的条目
|
||||
val keepCount = (maxCacheEntries * 0.8).toInt()
|
||||
val entriesToRemove = sortedEntries.drop(keepCount)
|
||||
|
||||
entriesToRemove.forEach { (key, _) ->
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
Log.d("FullscreenFilterManager", "${cacheName}缓存清理: 保留 $keepCount 条,移除 ${entriesToRemove.size} 条")
|
||||
}
|
||||
|
||||
// 清理过期条目
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val expiredKeys = cache.filter { (_, entry) ->
|
||||
currentTime - entry.timestamp > cacheExpirationTime
|
||||
}.keys.toList()
|
||||
|
||||
expiredKeys.forEach { key ->
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
if (expiredKeys.isNotEmpty()) {
|
||||
Log.d("FullscreenFilterManager", "${cacheName}缓存清理: 移除 ${expiredKeys.size} 个过期条目")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "${cacheName}缓存清理失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期清理内存(防止长时间运行导致内存泄漏)
|
||||
*/
|
||||
internal fun cleanupMemory() {
|
||||
try {
|
||||
// 清理数据缓冲区
|
||||
if (dataBuffer.size > maxBufferSize) {
|
||||
val excess = dataBuffer.size - maxBufferSize
|
||||
repeat(excess) {
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataBuffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
Log.d("FullscreenFilterManager", "清理了 $excess 个数据点")
|
||||
}
|
||||
|
||||
// 智能清理缓存
|
||||
cleanupCacheIfNeeded(originalDataCache, "原始数据")
|
||||
cleanupCacheIfNeeded(filteredDataCache, "滤波数据")
|
||||
|
||||
// 记录缓存状态
|
||||
Log.d("FullscreenFilterManager", "缓存状态: 原始数据${originalDataCache.size}条,滤波数据${filteredDataCache.size}条")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FullscreenFilterManager", "内存清理失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,157 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ class StreamingSignalProcessor {
|
|||
notchQ: Double
|
||||
): List<Float> {
|
||||
if (processorId == -1L) {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化")
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始数据")
|
||||
return newData
|
||||
}
|
||||
|
||||
|
|
@ -105,33 +105,18 @@ class StreamingSignalProcessor {
|
|||
|
||||
// 当缓冲区有足够数据时进行窗口处理
|
||||
while (dataBuffer.size >= windowSize) {
|
||||
// 提取当前窗口数据,过滤掉null值
|
||||
val windowDataList = dataBuffer.take(windowSize).filterNotNull()
|
||||
if (windowDataList.size < windowSize) {
|
||||
Log.w("StreamingSignalProcessor", "窗口数据包含null值,跳过处理")
|
||||
// 移除无效数据
|
||||
repeat(stepSize) {
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataBuffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
continue
|
||||
val windowData = dataBuffer.take(windowSize).toFloatArray()
|
||||
val processedWindow = processWindowWithParameters(windowData, sampleRate, lowpassCutoff, notchFreq, notchQ)
|
||||
|
||||
if (processedWindow.isNotEmpty()) {
|
||||
processedSamples.addAll(processedWindow)
|
||||
Log.d("StreamingSignalProcessor", "处理窗口成功,输出数据长度: ${processedWindow.size}")
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "窗口处理失败,使用原始数据")
|
||||
processedSamples.addAll(windowData.toList())
|
||||
}
|
||||
|
||||
val windowData = windowDataList.toFloatArray()
|
||||
Log.d("StreamingSignalProcessor", "提取窗口数据,长度: ${windowData.size}")
|
||||
|
||||
// 应用信号处理
|
||||
val processedWindow = processWindowWithParameters(windowData, sampleRate, lowpassCutoff, notchFreq, notchQ)
|
||||
Log.d("StreamingSignalProcessor", "窗口处理完成,结果长度: ${processedWindow.size}")
|
||||
|
||||
// 只保留非重叠部分的结果
|
||||
val nonOverlapSize = stepSize
|
||||
val nonOverlapData = processedWindow.take(nonOverlapSize)
|
||||
processedSamples.addAll(nonOverlapData)
|
||||
Log.d("StreamingSignalProcessor", "添加非重叠数据,长度: ${nonOverlapData.size}")
|
||||
|
||||
// 移除已处理的数据(保留重叠部分)
|
||||
// 移除已处理的数据
|
||||
repeat(stepSize) {
|
||||
if (dataBuffer.isNotEmpty()) {
|
||||
dataBuffer.removeAt(0)
|
||||
|
|
@ -139,10 +124,7 @@ class StreamingSignalProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
Log.d("StreamingSignalProcessor", "数据流处理完成,输出长度: ${processedSamples.size}")
|
||||
if (processedSamples.isNotEmpty()) {
|
||||
Log.d("StreamingSignalProcessor", "输出数据前3个值: ${processedSamples.take(3).joinToString(", ")}")
|
||||
}
|
||||
Log.d("StreamingSignalProcessor", "数据流处理完成,总共处理了 ${processedSamples.size} 个样本")
|
||||
|
||||
return processedSamples
|
||||
}
|
||||
|
|
@ -191,257 +173,133 @@ class StreamingSignalProcessor {
|
|||
filtered = lowpassResult
|
||||
Log.d("StreamingSignalProcessor", "低通滤波成功(${lowpassCutoff}Hz),结果前3个值: ${filtered.take(3).joinToString(", ")}")
|
||||
}
|
||||
} else {
|
||||
Log.d("StreamingSignalProcessor", "跳过低通滤波(低通截止频率为0)")
|
||||
}
|
||||
|
||||
// 3. 陷波滤波(去除工频干扰)- 默认关闭,用户可手动开启
|
||||
// 3. 陷波滤波(去除工频干扰)- 可选
|
||||
if (notchFreq > 0) {
|
||||
val notchFiltered = signalProcessor.notchFilter(filtered, sampleRate, notchFreq, notchQ)
|
||||
if (notchFiltered == null) {
|
||||
val notchResult = signalProcessor.notchFilter(filtered, sampleRate, notchFreq, notchQ)
|
||||
if (notchResult == null) {
|
||||
Log.w("StreamingSignalProcessor", "陷波滤波失败,使用低通滤波结果")
|
||||
Log.d("StreamingSignalProcessor", "返回低通滤波结果,前3个值: ${filtered.take(3).joinToString(", ")}")
|
||||
return filtered.toList()
|
||||
} else {
|
||||
Log.d("StreamingSignalProcessor", "陷波滤波成功(${notchFreq}Hz, Q=${notchQ}),结果前3个值: ${notchFiltered.take(3).joinToString(", ")}")
|
||||
return notchFiltered.toList()
|
||||
filtered = notchResult
|
||||
Log.d("StreamingSignalProcessor", "陷波滤波成功(${notchFreq}Hz),结果前3个值: ${filtered.take(3).joinToString(", ")}")
|
||||
}
|
||||
} else {
|
||||
Log.d("StreamingSignalProcessor", "跳过陷波滤波(默认关闭),返回低通滤波结果")
|
||||
return filtered.toList()
|
||||
}
|
||||
|
||||
Log.d("StreamingSignalProcessor", "窗口数据处理完成,最终结果长度: ${filtered.size}")
|
||||
return filtered.toList()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "窗口处理异常: ${e.message}")
|
||||
return windowData.toList()
|
||||
Log.e("StreamingSignalProcessor", "处理窗口数据异常: ${e.message}", e)
|
||||
return windowData.toList() // 异常时返回原始数据
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据数据类型获取合适的采样率
|
||||
*/
|
||||
private fun getSampleRateForDataType(dataType: type.SensorData.DataType): Double {
|
||||
return when (dataType) {
|
||||
type.SensorData.DataType.EEG -> 250.0
|
||||
type.SensorData.DataType.ECG_2LEAD, type.SensorData.DataType.ECG_12LEAD, type.SensorData.DataType.PW_ECG_SL -> 250.0
|
||||
type.SensorData.DataType.PPG -> 50.0
|
||||
type.SensorData.DataType.STETHOSCOPE -> 8000.0
|
||||
type.SensorData.DataType.SNORE -> 8000.0
|
||||
type.SensorData.DataType.RESPIRATION -> 50.0
|
||||
else -> 50.0 // 默认采样率
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据数据类型获取合适的滤波器参数
|
||||
* 返回格式: Triple<低通截止频率, 陷波频率, 陷波品质因数>
|
||||
*/
|
||||
private fun getFilterParametersForDataType(dataType: type.SensorData.DataType): Triple<Double, Double, Double> {
|
||||
return when (dataType) {
|
||||
type.SensorData.DataType.EEG -> {
|
||||
// EEG: 低通40Hz, 陷波50Hz
|
||||
Triple(40.0, 50.0, 30.0)
|
||||
}
|
||||
type.SensorData.DataType.ECG_2LEAD, type.SensorData.DataType.ECG_12LEAD, type.SensorData.DataType.PW_ECG_SL -> {
|
||||
// ECG: 优化滤波参数,提高实时性
|
||||
// 低通80Hz (减少计算量,保持信号质量)
|
||||
// 陷波50Hz (中国工频)
|
||||
// 品质因数5 (减少计算复杂度)
|
||||
Triple(80.0, 50.0, 5.0)
|
||||
}
|
||||
type.SensorData.DataType.PPG -> {
|
||||
// PPG: 低通10Hz, 陷波50Hz
|
||||
Triple(10.0, 50.0, 30.0)
|
||||
}
|
||||
type.SensorData.DataType.MIT_BIH -> {
|
||||
// MIT-BIH: 低通40Hz, 陷波50Hz
|
||||
Triple(40.0, 50.0, 30.0)
|
||||
}
|
||||
type.SensorData.DataType.STETHOSCOPE -> {
|
||||
// 听诊器: 低通4000Hz, 陷波50Hz
|
||||
Triple(4000.0, 50.0, 30.0)
|
||||
}
|
||||
type.SensorData.DataType.SNORE -> {
|
||||
// 鼾声: 低通4000Hz, 陷波50Hz
|
||||
Triple(4000.0, 50.0, 30.0)
|
||||
}
|
||||
type.SensorData.DataType.RESPIRATION -> {
|
||||
// 呼吸: 低通1Hz, 不陷波
|
||||
Triple(1.0, 0.0, 0.0)
|
||||
}
|
||||
else -> {
|
||||
// 默认参数
|
||||
Triple(40.0, 50.0, 30.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理传感器数据包
|
||||
*/
|
||||
fun processSensorData(sensorData: SensorData): SensorData {
|
||||
val channelData = sensorData.getChannelData()
|
||||
if (channelData == null || channelData.isEmpty()) {
|
||||
return sensorData
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据数据类型设置采样率和滤波器参数
|
||||
val dataType = sensorData.getDataType()
|
||||
val sampleRate = getSampleRateForDataType(dataType)
|
||||
val (lowpassCutoff, notchFreq, notchQ) = getFilterParametersForDataType(dataType)
|
||||
|
||||
Log.d("StreamingSignalProcessor", "处理数据类型: $dataType, 采样率: ${sampleRate}Hz, 低通: ${lowpassCutoff}Hz, 陷波: ${notchFreq}Hz")
|
||||
|
||||
// 处理每个通道
|
||||
val processedChannels = channelData.map { channel ->
|
||||
val channelData = channel.toFloatArray()
|
||||
val processedData = processStreamingDataWithParameters(channelData.toList(), sampleRate, lowpassCutoff, notchFreq, notchQ)
|
||||
processedData.toFloatArray()
|
||||
}
|
||||
|
||||
// 创建处理后的传感器数据
|
||||
val processedSensorData = SensorData()
|
||||
processedSensorData.setDataType(sensorData.getDataType())
|
||||
processedSensorData.setTimestamp(sensorData.getTimestamp())
|
||||
processedSensorData.setPacketSn(sensorData.getPacketSn())
|
||||
processedSensorData.setChannelData(processedChannels.map { it.toList() })
|
||||
|
||||
return processedSensorData
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "处理传感器数据异常: ${e.message}")
|
||||
return sensorData
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量处理传感器数据包
|
||||
*/
|
||||
fun processSensorDataList(sensorDataList: List<SensorData>): List<SensorData> {
|
||||
return sensorDataList.map { processSensorData(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓冲区状态
|
||||
*/
|
||||
fun getBufferStatus(): Map<String, Int> {
|
||||
return mapOf(
|
||||
"buffer_size" to dataBuffer.size,
|
||||
"window_size" to windowSize,
|
||||
"overlap_size" to overlapSize,
|
||||
"step_size" to stepSize
|
||||
"dataBufferSize" to dataBuffer.size,
|
||||
"processedDataSize" to processedData.size,
|
||||
"windowSize" to windowSize,
|
||||
"overlapSize" to overlapSize,
|
||||
"stepSize" to stepSize
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓冲区
|
||||
* 计算信号质量
|
||||
*/
|
||||
fun clearBuffer() {
|
||||
dataBuffer.clear()
|
||||
processedData.clear()
|
||||
lastProcessedIndex = 0
|
||||
Log.d("StreamingSignalProcessor", "缓冲区已清空")
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置窗口参数
|
||||
*/
|
||||
fun setWindowParameters(windowSize: Int, overlapSize: Int) {
|
||||
this.windowSize = windowSize
|
||||
this.overlapSize = overlapSize
|
||||
this.stepSize = windowSize - overlapSize
|
||||
Log.d("StreamingSignalProcessor", "窗口参数已更新: 窗口大小=$windowSize, 重叠大小=$overlapSize")
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置滤波器参数
|
||||
*/
|
||||
fun setFilterParameters(lowpassCutoff: Double, notchFreq: Double, notchQ: Double) {
|
||||
this.lowpassCutoff = lowpassCutoff
|
||||
this.notchFreq = notchFreq
|
||||
this.notchQ = notchQ
|
||||
Log.d("StreamingSignalProcessor", "滤波器参数已更新")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
if (processorId != -1L) {
|
||||
signalProcessor.destroyProcessor()
|
||||
processorId = -1L
|
||||
}
|
||||
clearBuffer()
|
||||
Log.d("StreamingSignalProcessor", "流式信号处理器资源已清理")
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "清理资源时发生错误: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 委托给SignalProcessorJNI的方法
|
||||
fun normalizeAmplitude(signal: FloatArray): FloatArray? {
|
||||
if (signalProcessorInitialized) {
|
||||
signalProcessor.normalizeAmplitude(signal)
|
||||
return signal // normalizeAmplitude修改原数组,返回原数组
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
return signal
|
||||
}
|
||||
}
|
||||
|
||||
fun bandpassFilter(signal: FloatArray, sampleRate: Float, lowFreq: Float, highFreq: Float): FloatArray? {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.bandpassFilter(signal, sampleRate.toDouble(), lowFreq.toDouble(), highFreq.toDouble())
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
fun highpassFilter(signal: FloatArray, sampleRate: Float, cutoffFreq: Float): FloatArray? {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.highpassFilter(signal, sampleRate.toDouble(), cutoffFreq.toDouble())
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
fun lowpassFilter(signal: FloatArray, sampleRate: Float, cutoffFreq: Float): FloatArray? {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.lowpassFilter(signal, sampleRate.toDouble(), cutoffFreq.toDouble())
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
fun notchFilter(signal: FloatArray, sampleRate: Float, notchFreq: Float): FloatArray? {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.notchFilter(signal, sampleRate.toDouble(), notchFreq.toDouble())
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
fun processRealtimeChunk(signal: FloatArray, sampleRate: Float): FloatArray? {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.processRealtimeChunk(signal, sampleRate.toDouble())
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回原始信号")
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateSignalQuality(signal: FloatArray): Float {
|
||||
return if (signalProcessorInitialized) {
|
||||
signalProcessor.calculateSignalQuality(signal)
|
||||
} else {
|
||||
Log.w("StreamingSignalProcessor", "信号处理器未初始化,返回默认质量值")
|
||||
0.0f
|
||||
if (signal.isEmpty()) return 0.0f
|
||||
|
||||
try {
|
||||
// 简单的信号质量评估:基于信号幅度和噪声水平
|
||||
val mean = signal.average().toFloat()
|
||||
val variance = signal.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||
val stdDev = kotlin.math.sqrt(variance)
|
||||
|
||||
// 信号质量评分(0-1)
|
||||
val quality = kotlin.math.min(1.0f, stdDev / 100.0f)
|
||||
return quality
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "计算信号质量失败: ${e.message}")
|
||||
return 0.0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带通滤波
|
||||
*/
|
||||
fun bandpassFilter(signal: FloatArray, sampleRate: Float, lowCutoff: Float, highCutoff: Float): FloatArray? {
|
||||
return try {
|
||||
signalProcessor.bandpassFilter(signal, sampleRate.toDouble(), lowCutoff.toDouble(), highCutoff.toDouble())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "带通滤波失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高通滤波
|
||||
*/
|
||||
fun highpassFilter(signal: FloatArray, sampleRate: Float, cutoff: Float): FloatArray? {
|
||||
return try {
|
||||
signalProcessor.highpassFilter(signal, sampleRate.toDouble(), cutoff.toDouble())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "高通滤波失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 低通滤波
|
||||
*/
|
||||
fun lowpassFilter(signal: FloatArray, sampleRate: Float, cutoff: Float): FloatArray? {
|
||||
return try {
|
||||
signalProcessor.lowpassFilter(signal, sampleRate.toDouble(), cutoff.toDouble())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "低通滤波失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陷波滤波
|
||||
*/
|
||||
fun notchFilter(signal: FloatArray, sampleRate: Float, frequency: Float, q: Float): FloatArray? {
|
||||
return try {
|
||||
signalProcessor.notchFilter(signal, sampleRate.toDouble(), frequency.toDouble(), q.toDouble())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "陷波滤波失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幅度归一化
|
||||
*/
|
||||
fun normalizeAmplitude(signal: FloatArray): FloatArray? {
|
||||
return try {
|
||||
val result = signal.copyOf()
|
||||
signalProcessor.normalizeAmplitude(result)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "幅度归一化失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时处理数据块
|
||||
*/
|
||||
fun processRealtimeChunk(signal: FloatArray, sampleRate: Float): FloatArray? {
|
||||
return try {
|
||||
signalProcessor.processRealtimeChunk(signal, sampleRate.toDouble())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamingSignalProcessor", "实时处理失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#FFFFFF">
|
||||
|
||||
<!-- 全屏图表容器 - 直接占满整个屏幕 -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFFFFF">
|
||||
|
||||
<!-- 生命体征显示区域 - 浮动在左上角 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_margin="16dp"
|
||||
android:orientation="vertical"
|
||||
android:background="#80000000"
|
||||
android:padding="12dp"
|
||||
android:elevation="4dp">
|
||||
|
||||
<!-- 心率显示 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="4dp">
|
||||
<TextView
|
||||
android:id="@+id/heart_rate_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="心率 (bpm)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
<TextView
|
||||
android:id="@+id/heart_rate_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textColor="#00FF00"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 血氧显示 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="4dp">
|
||||
<TextView
|
||||
android:id="@+id/spo2_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="血氧 (%)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
<TextView
|
||||
android:id="@+id/spo2_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textColor="#0080FF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 体温显示 -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
<TextView
|
||||
android:id="@+id/temperature_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="体温 (°C)"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
<TextView
|
||||
android:id="@+id/temperature_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textColor="#FF4040"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 滤波器按键 - 浮动在右上角 -->
|
||||
<Button
|
||||
android:id="@+id/filter_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="滤波器"
|
||||
android:textSize="12sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#FFFFFF"
|
||||
android:padding="8dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="16dp" />
|
||||
|
||||
<!-- 退出全屏按钮 - 浮动在右上角,位于滤波器按键下方 -->
|
||||
<Button
|
||||
android:id="@+id/exit_fullscreen_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="退出全屏"
|
||||
android:textSize="12sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#FFFFFF"
|
||||
android:padding="8dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="60dp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<!-- 可滚动的全屏图表容器 -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/fullscreen_charts_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="0dp">
|
||||
|
||||
<!-- 全屏图表将在这里动态添加 -->
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
@ -111,11 +111,43 @@
|
|||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 第四行按钮 - 图表控制和调试 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/reset_charts_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="重置图表"
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#FFFFFF"
|
||||
android:enabled="false" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/debug_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="调试"
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ECG图表区域 -->
|
||||
<!-- 动态图表区域 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/ecg_chart_container"
|
||||
android:id="@+id/dynamic_chart_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="0.7"
|
||||
|
|
@ -132,38 +164,37 @@
|
|||
android:background="#E0E0E0">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chart_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="ECG实时监测"
|
||||
android:text="生理信号实时监测"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333"
|
||||
android:gravity="center" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ECG节律视图 -->
|
||||
<com.example.cmake_project_test.ECGRhythmView
|
||||
android:id="@+id/ecg_rhythm_view"
|
||||
<!-- 可滚动的图表容器 -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="0.5"
|
||||
android:background="#F8F8F8" />
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#CCCCCC" />
|
||||
<LinearLayout
|
||||
android:id="@+id/charts_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="4dp">
|
||||
|
||||
<!-- ECG波形视图 -->
|
||||
<com.example.cmake_project_test.ECGWaveformView
|
||||
android:id="@+id/ecg_waveform_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="0.5"
|
||||
android:background="#F0F0F0" />
|
||||
<!-- 图表将在这里动态添加 -->
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxHeight="600dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp"
|
||||
android:background="#FFFFFF">
|
||||
|
||||
<!-- 对话框标题 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="滤波器设置"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- 滤波器说明 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="可以选择多个滤波器同时应用,滤波器会按顺序叠加处理"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- 低通滤波器设置 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:background="#F5F5F5"
|
||||
android:padding="15dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="低通滤波"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/lowpass_enable_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="去除高频噪声,保留低频信号"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="截止频率:"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/lowpass_frequency_input"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="40"
|
||||
android:textSize="14sp"
|
||||
android:inputType="numberDecimal"
|
||||
android:background="@android:drawable/edit_text"
|
||||
android:padding="8dp"
|
||||
android:layout_marginEnd="5dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hz"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 高通滤波器设置 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:background="#F5F5F5"
|
||||
android:padding="15dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="高通滤波"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/highpass_enable_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="去除基线漂移,保留高频信号"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="截止频率:"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/highpass_frequency_input"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1"
|
||||
android:textSize="14sp"
|
||||
android:inputType="numberDecimal"
|
||||
android:background="@android:drawable/edit_text"
|
||||
android:padding="8dp"
|
||||
android:layout_marginEnd="5dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hz"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 50Hz陷波滤波器设置 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:background="#F5F5F5"
|
||||
android:padding="15dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="50Hz陷波滤波"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/notch_enable_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="去除工频干扰(50Hz及其谐波)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="陷波频率:"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/notch_frequency_input"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="50"
|
||||
android:textSize="14sp"
|
||||
android:inputType="numberDecimal"
|
||||
android:background="@android:drawable/edit_text"
|
||||
android:padding="8dp"
|
||||
android:layout_marginEnd="5dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hz"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="取消"
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#666666"
|
||||
android:padding="12dp"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_apply"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="应用"
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/button_background"
|
||||
android:textColor="#FFFFFF"
|
||||
android:padding="12dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
<resources>
|
||||
<string name="app_name">Cmake_project_test</string>
|
||||
<string name="app_name">生理信号监测</string>
|
||||
<string name="app_description">多设备生理信号实时监测与分析应用</string>
|
||||
</resources>
|
||||
|
|
@ -13,4 +13,21 @@
|
|||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
<!-- 全屏主题 -->
|
||||
<style name="Theme.Cmake_project_test.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<!-- 全屏模式 -->
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-files-path name="my_files" path="." />
|
||||
<cache-path name="my_cache" path="." />
|
||||
</paths>
|
||||
141
应用运行状态总结.md
141
应用运行状态总结.md
|
|
@ -1,141 +0,0 @@
|
|||
# 应用运行状态总结
|
||||
|
||||
## ✅ 编译和安装状态
|
||||
|
||||
### 编译成功
|
||||
- **编译命令**: `./gradlew assembleDebug`
|
||||
- **状态**: ✅ 成功
|
||||
- **错误**: 无
|
||||
|
||||
### 安装成功
|
||||
- **安装命令**: `./gradlew installDebug`
|
||||
- **状态**: ✅ 成功
|
||||
- **错误**: 无
|
||||
|
||||
### 代码质量检查
|
||||
- **Lint检查**: `./gradlew lintDebug`
|
||||
- **状态**: ✅ 通过
|
||||
- **警告**: 无
|
||||
|
||||
## 🔧 修复的问题
|
||||
|
||||
### 1. 缺少Build类导入
|
||||
**问题**: MainActivity中使用`Build.VERSION.SDK_INT`但未导入Build类
|
||||
**修复**: 添加了`import android.os.Build`
|
||||
**状态**: ✅ 已修复
|
||||
|
||||
### 2. 权限配置优化
|
||||
**问题**: 权限配置不够完善,特别是Android 12+的支持
|
||||
**修复**:
|
||||
- 更新了AndroidManifest.xml权限声明
|
||||
- 添加了动态权限检查
|
||||
- 增强了权限状态显示
|
||||
**状态**: ✅ 已修复
|
||||
|
||||
### 3. 蓝牙扫描功能增强
|
||||
**问题**: 扫描功能不够详细,缺少错误处理
|
||||
**修复**:
|
||||
- 延长扫描时间到15秒
|
||||
- 添加详细的设备信息显示
|
||||
- 增强错误处理和状态提示
|
||||
**状态**: ✅ 已修复
|
||||
|
||||
## 📱 应用功能状态
|
||||
|
||||
### 核心功能
|
||||
- [x] **蓝牙权限管理** - 完整的权限检查和请求
|
||||
- [x] **蓝牙设备扫描** - 支持BLE和传统蓝牙扫描
|
||||
- [x] **设备连接管理** - 连接、断开、状态监控
|
||||
- [x] **数据收发功能** - 支持各种指令发送
|
||||
- [x] **UI状态显示** - 详细的状态信息和提示
|
||||
|
||||
### 权限支持
|
||||
- [x] **Android 11及以下** - 需要位置权限
|
||||
- [x] **Android 12+** - 使用新蓝牙权限模型
|
||||
- [x] **动态权限检查** - 根据Android版本自动适配
|
||||
- [x] **权限状态显示** - 详细的权限状态反馈
|
||||
|
||||
### 蓝牙功能
|
||||
- [x] **BLE扫描** - 低功耗蓝牙设备扫描
|
||||
- [x] **传统蓝牙扫描** - 经典蓝牙设备扫描
|
||||
- [x] **设备连接** - 支持直接MAC地址连接
|
||||
- [x] **服务发现** - 自动发现蓝牙服务和特征
|
||||
- [x] **数据通道** - 建立数据收发通道
|
||||
|
||||
## 🎯 目标设备支持
|
||||
|
||||
### 目标设备: `A4:C3:37:86:9F:73`
|
||||
- [x] **MAC地址验证** - 支持多种格式验证
|
||||
- [x] **直接连接** - 长按蓝牙按钮直接连接
|
||||
- [x] **设备识别** - 扫描时自动识别目标设备
|
||||
- [x] **连接重试** - 超时后自动重试连接
|
||||
|
||||
## 📊 测试建议
|
||||
|
||||
### 1. 权限测试
|
||||
1. **启动应用**
|
||||
2. **观察权限状态显示**
|
||||
3. **授予必要权限**
|
||||
4. **确认权限状态更新**
|
||||
|
||||
### 2. 蓝牙扫描测试
|
||||
1. **点击"连接蓝牙"按钮**
|
||||
2. **观察扫描过程状态**
|
||||
3. **检查设备发现信息**
|
||||
4. **验证目标设备识别**
|
||||
|
||||
### 3. 连接测试
|
||||
1. **长按"连接蓝牙"按钮**
|
||||
2. **输入目标MAC地址**
|
||||
3. **观察连接过程**
|
||||
4. **验证连接状态**
|
||||
|
||||
### 4. 数据收发测试
|
||||
1. **建立连接后**
|
||||
2. **点击"发送指令"按钮**
|
||||
3. **测试各种指令发送**
|
||||
4. **验证数据接收**
|
||||
|
||||
## 🚀 下一步操作
|
||||
|
||||
### 立即测试
|
||||
1. **在目标设备上启动应用**
|
||||
2. **检查权限授予情况**
|
||||
3. **测试蓝牙扫描功能**
|
||||
4. **尝试连接目标设备**
|
||||
|
||||
### 如果遇到问题
|
||||
1. **查看应用状态信息**
|
||||
2. **检查Android Studio Logcat**
|
||||
3. **参考故障排除指南**
|
||||
4. **提供详细错误信息**
|
||||
|
||||
## 📋 技术规格
|
||||
|
||||
### 编译环境
|
||||
- **Gradle版本**: 8.0+
|
||||
- **Android Gradle Plugin**: 8.0+
|
||||
- **目标SDK**: 34
|
||||
- **最低SDK**: 21
|
||||
|
||||
### 权限要求
|
||||
- **BLUETOOTH_SCAN** - 蓝牙扫描权限
|
||||
- **BLUETOOTH_CONNECT** - 蓝牙连接权限
|
||||
- **ACCESS_FINE_LOCATION** - 精确位置权限(Android 11及以下)
|
||||
- **ACCESS_COARSE_LOCATION** - 粗略位置权限(Android 11及以下)
|
||||
|
||||
### 蓝牙支持
|
||||
- **BLE (Bluetooth Low Energy)** - 低功耗蓝牙
|
||||
- **传统蓝牙** - 经典蓝牙协议
|
||||
- **双模支持** - 自动检测和切换
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
应用已成功编译、安装并修复了所有已知问题。主要功能包括:
|
||||
|
||||
1. **完整的权限管理** - 支持所有Android版本的权限模型
|
||||
2. **强大的蓝牙功能** - 扫描、连接、数据收发
|
||||
3. **友好的用户界面** - 详细的状态信息和提示
|
||||
4. **目标设备支持** - 专门针对目标设备的优化
|
||||
|
||||
应用现在可以正常运行,建议立即在目标设备上进行测试!
|
||||
116
快速故障排除指南.md
116
快速故障排除指南.md
|
|
@ -1,116 +0,0 @@
|
|||
# 快速故障排除指南
|
||||
|
||||
## 当前问题:客户端找不到SerialTest服务器
|
||||
|
||||
### 立即检查清单
|
||||
|
||||
#### ✅ 手机端检查
|
||||
- [ ] 蓝牙已开启
|
||||
- [ ] 蓝牙可见性设置为"始终可见"
|
||||
- [ ] 已清除蓝牙缓存
|
||||
- [ ] 重启过蓝牙服务
|
||||
|
||||
#### ✅ 电脑端检查
|
||||
- [ ] SerialTest正在运行
|
||||
- [ ] 选择了正确的COM端口
|
||||
- [ ] 启用了蓝牙服务器模式
|
||||
- [ ] 电脑蓝牙已开启
|
||||
- [ ] 电脑蓝牙设置为"可发现"
|
||||
|
||||
#### ✅ 环境检查
|
||||
- [ ] 设备距离在10米内
|
||||
- [ ] 远离其他蓝牙设备干扰
|
||||
- [ ] 没有金属屏蔽物
|
||||
|
||||
### 快速测试步骤
|
||||
|
||||
#### 步骤1:基础扫描测试
|
||||
1. **启动Android应用**
|
||||
2. **点击"连接蓝牙"按钮**(普通点击)
|
||||
3. **观察扫描结果**:
|
||||
- 是否显示"🔍 开始扫描蓝牙设备..."
|
||||
- 是否显示"📡 使用BLE扫描模式"或"📡 使用传统蓝牙扫描模式"
|
||||
- 是否发现任何设备
|
||||
|
||||
#### 步骤2:直接连接测试
|
||||
1. **长按"连接蓝牙"按钮**
|
||||
2. **输入MAC地址**:`60:E9:AA:30:8B:0A`
|
||||
3. **点击连接**
|
||||
4. **观察连接状态**
|
||||
|
||||
#### 步骤3:系统蓝牙测试
|
||||
1. **打开手机蓝牙设置**
|
||||
2. **扫描设备**
|
||||
3. **查看是否发现您的电脑设备**
|
||||
4. **如果发现,尝试配对**
|
||||
|
||||
### 常见问题及解决方案
|
||||
|
||||
#### 问题1:扫描不到任何设备
|
||||
**可能原因**:
|
||||
- 目标设备蓝牙未开启
|
||||
- 设备不在可见范围内
|
||||
- 蓝牙服务异常
|
||||
|
||||
**解决方案**:
|
||||
1. 重启手机蓝牙
|
||||
2. 重启电脑蓝牙
|
||||
3. 检查设备距离
|
||||
4. 使用系统蓝牙设置测试
|
||||
|
||||
#### 问题2:扫描到设备但连接失败
|
||||
**可能原因**:
|
||||
- 设备未配对
|
||||
- 协议不兼容
|
||||
- SerialTest配置错误
|
||||
|
||||
**解决方案**:
|
||||
1. 在系统蓝牙设置中手动配对
|
||||
2. 检查SerialTest配置
|
||||
3. 确认波特率设置
|
||||
|
||||
#### 问题3:连接成功但无法通信
|
||||
**可能原因**:
|
||||
- 数据格式不匹配
|
||||
- 波特率设置错误
|
||||
- 协议不兼容
|
||||
|
||||
**解决方案**:
|
||||
1. 检查SerialTest数据格式设置
|
||||
2. 尝试不同的波特率
|
||||
3. 发送测试数据验证
|
||||
|
||||
### 调试信息查看
|
||||
|
||||
#### Android Studio Logcat
|
||||
过滤标签:`BluetoothManager`
|
||||
查看以下信息:
|
||||
- 扫描开始和结束
|
||||
- 设备发现详情
|
||||
- 连接状态变化
|
||||
- 错误信息
|
||||
|
||||
#### 应用状态显示
|
||||
应用会显示:
|
||||
- 🔍 扫描状态
|
||||
- 📱 发现的设备信息
|
||||
- ✅ 连接成功提示
|
||||
- ❌ 错误信息
|
||||
|
||||
### 下一步操作
|
||||
|
||||
1. **按照检查清单逐一确认**
|
||||
2. **执行快速测试步骤**
|
||||
3. **记录每个步骤的结果**
|
||||
4. **查看Logcat日志**
|
||||
5. **如果问题持续,提供详细错误信息**
|
||||
|
||||
### 需要提供的信息
|
||||
|
||||
如果问题仍然存在,请提供:
|
||||
1. **手机型号和Android版本**
|
||||
2. **电脑操作系统版本**
|
||||
3. **SerialTest版本和配置截图**
|
||||
4. **应用扫描过程的截图**
|
||||
5. **Logcat中的错误日志**
|
||||
6. **系统蓝牙设置中的设备列表截图**
|
||||
105
快速测试指南.md
105
快速测试指南.md
|
|
@ -1,105 +0,0 @@
|
|||
# 快速测试指南
|
||||
|
||||
## 您的代码数据流分析
|
||||
|
||||
### 数据流路径:
|
||||
```
|
||||
蓝牙设备 → BluetoothManager → DataManager → 信号处理 → 图表显示
|
||||
```
|
||||
|
||||
### 详细流程:
|
||||
1. **BluetoothManager** 负责:
|
||||
- 扫描和连接蓝牙设备
|
||||
- 接收蓝牙数据
|
||||
- 发送指令到设备
|
||||
|
||||
2. **DataManager** 负责:
|
||||
- 解析接收到的数据包
|
||||
- 应用信号处理算法(滤波、陷波等)
|
||||
- 计算心率等指标
|
||||
- 通过回调发送数据到UI
|
||||
|
||||
3. **实时显示**:
|
||||
- ECG节律视图和波形视图
|
||||
- 实时更新图表数据
|
||||
- 显示处理进度和状态
|
||||
|
||||
## 测试您的电脑连接
|
||||
|
||||
### 方法1:直接连接(推荐)
|
||||
1. 启动应用
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. 输入您的电脑MAC地址:`60:E9:AA:30:8B:0A`(使用冒号分隔符)
|
||||
4. 点击"连接"
|
||||
5. 观察连接状态
|
||||
|
||||
### 方法2:扫描连接
|
||||
1. 点击"连接蓝牙"按钮
|
||||
2. 等待扫描完成
|
||||
3. 在设备列表中选择您的电脑
|
||||
4. 点击连接
|
||||
|
||||
## 数据收发测试
|
||||
|
||||
### 发送测试数据:
|
||||
1. 连接成功后,**长按"发送指令"按钮**
|
||||
2. 选择测试选项:
|
||||
- **发送ECG测试数据**:模拟ECG数据包
|
||||
- **发送心跳包**:简单连接测试
|
||||
- **发送设备信息查询**:查询设备状态
|
||||
- **发送自定义测试数据**:自定义十六进制数据
|
||||
|
||||
### 接收测试:
|
||||
1. 点击"启动程序"按钮
|
||||
2. 观察图表是否显示测试数据
|
||||
3. 查看状态信息区域的数据接收情况
|
||||
|
||||
## 图表显示测试
|
||||
|
||||
### 立即测试:
|
||||
- **点击"启动程序"** → 生成简单测试数据
|
||||
- **长按"启动程序"** → 生成复杂ECG波形
|
||||
- **点击"清空数据"** → 清空图表数据
|
||||
|
||||
### 实时显示:
|
||||
- 连接成功后,图表会自动显示接收到的数据
|
||||
- 支持实时滤波和信号处理
|
||||
- 双视图显示:节律视图 + 波形视图
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 查看日志:
|
||||
在Android Studio中打开Logcat,过滤标签:
|
||||
- `MainActivity`:主要操作日志
|
||||
- `BluetoothManager`:蓝牙连接日志
|
||||
- `DataManager`:数据处理日志
|
||||
|
||||
### 常见问题:
|
||||
1. **连接失败**:检查权限和MAC地址
|
||||
2. **数据不显示**:点击"启动程序"生成测试数据
|
||||
3. **图表空白**:检查图表容器可见性
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **先测试连接**:确保能连接到您的电脑
|
||||
2. **再测试发送**:发送各种测试数据
|
||||
3. **最后测试接收**:验证数据接收和显示
|
||||
4. **查看日志**:通过Logcat监控详细过程
|
||||
|
||||
## 数据格式说明
|
||||
|
||||
### ECG测试数据包:
|
||||
```
|
||||
AA 55 01 [长度] [ECG数据...] [校验和]
|
||||
```
|
||||
|
||||
### 自定义数据:
|
||||
支持十六进制格式,如:`01 02 03 04 05`
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 编译并运行应用
|
||||
2. 按照上述步骤测试连接
|
||||
3. 验证数据收发功能
|
||||
4. 检查图表显示效果
|
||||
5. 查看日志确认各环节正常
|
||||
120
手机连接测试指南.md
120
手机连接测试指南.md
|
|
@ -1,120 +0,0 @@
|
|||
# 手机连接测试指南
|
||||
|
||||
## 目标设备信息
|
||||
- **设备类型**: 手机
|
||||
- **MAC地址**: `A4:C3:37:86:9F:73`
|
||||
- **连接方式**: 蓝牙
|
||||
|
||||
## 连接步骤
|
||||
|
||||
### 第一步:准备目标手机
|
||||
1. **确保目标手机蓝牙已开启**
|
||||
2. **设置蓝牙可见性**:
|
||||
- 打开设置 → 蓝牙
|
||||
- 确保蓝牙已开启
|
||||
- 设置为"可发现"或"始终可见"
|
||||
3. **检查配对状态**:
|
||||
- 如果之前配对过,建议先取消配对
|
||||
- 重新开始配对过程
|
||||
|
||||
### 第二步:使用应用连接
|
||||
1. **启动Android应用**
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. **输入MAC地址**:`A4:C3:37:86:9F:73`
|
||||
4. **点击"连接"按钮**
|
||||
|
||||
### 第三步:观察连接状态
|
||||
应用会显示以下状态信息:
|
||||
- 🔍 **开始扫描蓝牙设备...**
|
||||
- ✅ **权限检查通过,开始扫描...**
|
||||
- 📡 **使用BLE扫描模式**或**使用传统蓝牙扫描模式**
|
||||
- 📱 **发现设备: [设备名称]**
|
||||
- 🎯 **找到目标设备!**(如果发现目标手机)
|
||||
- ✅ **设备已连接: [设备名称]**
|
||||
- 🔍 **服务发现成功**
|
||||
- 📡 **数据通道已建立,可以发送指令开始接收数据**
|
||||
|
||||
## 预期结果
|
||||
|
||||
### 连接成功时:
|
||||
- 按钮变为"断开蓝牙"(红色)
|
||||
- "发送指令"按钮启用(蓝色)
|
||||
- 显示详细的设备信息
|
||||
- 服务发现成功
|
||||
- 数据通道建立
|
||||
|
||||
### 连接失败时:
|
||||
- 显示错误信息
|
||||
- 按钮保持"连接蓝牙"状态
|
||||
- 提供可能的解决方案
|
||||
|
||||
## 测试数据发送
|
||||
|
||||
连接成功后,可以测试数据通信:
|
||||
|
||||
### 1. 发送测试数据
|
||||
1. **长按"发送指令"按钮**
|
||||
2. **选择测试数据类型**:
|
||||
- 发送ECG测试数据
|
||||
- 发送心跳包
|
||||
- 发送设备信息查询
|
||||
- 发送自定义测试数据
|
||||
|
||||
### 2. 观察数据接收
|
||||
- 在目标手机上查看是否收到数据
|
||||
- 检查数据格式是否正确
|
||||
- 验证通信是否双向
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 如果扫描不到设备:
|
||||
1. **检查目标手机蓝牙设置**
|
||||
2. **确保设备在10米范围内**
|
||||
3. **重启两台设备的蓝牙**
|
||||
4. **使用系统蓝牙设置测试**
|
||||
|
||||
### 如果连接失败:
|
||||
1. **检查MAC地址是否正确**
|
||||
2. **确认目标手机蓝牙已开启**
|
||||
3. **尝试在系统蓝牙设置中手动配对**
|
||||
4. **查看Logcat日志获取详细错误信息**
|
||||
|
||||
### 如果连接成功但无法通信:
|
||||
1. **检查目标手机是否支持相应的蓝牙服务**
|
||||
2. **确认数据格式是否兼容**
|
||||
3. **尝试发送不同类型的测试数据**
|
||||
|
||||
## 调试信息
|
||||
|
||||
### Android Studio Logcat
|
||||
过滤标签:`BluetoothManager`
|
||||
查看以下关键信息:
|
||||
- 扫描开始和结束
|
||||
- 设备发现详情
|
||||
- 连接状态变化
|
||||
- 服务发现过程
|
||||
- 数据收发情况
|
||||
|
||||
### 应用状态显示
|
||||
应用会实时显示:
|
||||
- 扫描进度
|
||||
- 发现的设备信息
|
||||
- 连接状态
|
||||
- 错误信息
|
||||
|
||||
## 下一步操作
|
||||
|
||||
1. **按照上述步骤连接目标手机**
|
||||
2. **观察连接过程的状态信息**
|
||||
3. **测试数据收发功能**
|
||||
4. **记录任何错误或异常情况**
|
||||
5. **如果遇到问题,查看Logcat日志**
|
||||
|
||||
## 需要提供的信息
|
||||
|
||||
如果连接失败,请提供:
|
||||
1. **目标手机型号和Android版本**
|
||||
2. **应用显示的状态信息**
|
||||
3. **Logcat中的错误日志**
|
||||
4. **目标手机蓝牙设置截图**
|
||||
5. **连接过程的详细描述**
|
||||
143
测试总结.md
143
测试总结.md
|
|
@ -1,143 +0,0 @@
|
|||
# 蓝牙连接测试总结
|
||||
|
||||
## 您的代码数据流分析
|
||||
|
||||
### 完整数据流:
|
||||
```
|
||||
蓝牙设备 (60-E9-AA-30-8B-0A)
|
||||
↓
|
||||
BluetoothManager (连接管理)
|
||||
↓
|
||||
DataManager (数据解析和处理)
|
||||
↓
|
||||
信号处理 (滤波、陷波等)
|
||||
↓
|
||||
实时图表显示 (ECG双视图)
|
||||
```
|
||||
|
||||
### 各组件功能:
|
||||
|
||||
1. **BluetoothManager**:
|
||||
- 扫描和连接蓝牙设备
|
||||
- 接收蓝牙数据流
|
||||
- 发送指令到设备
|
||||
- 管理连接状态
|
||||
|
||||
2. **DataManager**:
|
||||
- 解析接收到的数据包
|
||||
- 应用信号处理算法
|
||||
- 计算心率等指标
|
||||
- 通过回调发送数据到UI
|
||||
|
||||
3. **实时显示系统**:
|
||||
- ECG节律视图
|
||||
- ECG波形视图
|
||||
- 实时数据更新
|
||||
- 状态信息显示
|
||||
|
||||
## 已添加的测试功能
|
||||
|
||||
### 1. 直接连接功能
|
||||
- **长按"连接蓝牙"按钮** → 弹出直接连接对话框
|
||||
- 预设您的电脑MAC地址:`60:E9:AA:30:8B:0A`(使用冒号分隔符)
|
||||
- 支持自定义MAC地址输入
|
||||
|
||||
### 2. 测试数据发送功能
|
||||
- **长按"发送指令"按钮** → 弹出测试数据对话框
|
||||
- 支持多种测试数据类型:
|
||||
- ECG测试数据包
|
||||
- 心跳包
|
||||
- 设备信息查询
|
||||
- 自定义十六进制数据
|
||||
|
||||
### 3. 图表显示测试
|
||||
- **点击"启动程序"** → 立即生成测试数据
|
||||
- **长按"启动程序"** → 生成复杂ECG波形
|
||||
- **点击"清空数据"** → 清空图表数据
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 第一步:连接测试
|
||||
1. 启动应用
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. 输入MAC地址:`60:E9:AA:30:8B:0A`(使用冒号分隔符)
|
||||
4. 点击"连接"
|
||||
5. 观察连接状态
|
||||
|
||||
### 第二步:发送测试
|
||||
1. 连接成功后,**长按"发送指令"按钮**
|
||||
2. 选择测试数据类型
|
||||
3. 发送测试数据
|
||||
4. 观察发送状态
|
||||
|
||||
### 第三步:接收测试
|
||||
1. 点击"启动程序"按钮
|
||||
2. 观察图表显示
|
||||
3. 查看数据接收情况
|
||||
|
||||
### 第四步:图表测试
|
||||
1. 长按"启动程序"按钮
|
||||
2. 观察ECG波形显示
|
||||
3. 测试陷波滤波器功能
|
||||
|
||||
## 调试信息
|
||||
|
||||
### 日志标签:
|
||||
- `MainActivity`:主要操作日志
|
||||
- `BluetoothManager`:蓝牙连接日志
|
||||
- `DataManager`:数据处理日志
|
||||
|
||||
### 状态显示:
|
||||
- 连接状态实时更新
|
||||
- 数据接收进度显示
|
||||
- 错误信息详细提示
|
||||
|
||||
## 数据格式
|
||||
|
||||
### ECG测试数据包:
|
||||
```
|
||||
AA 55 01 [长度低字节] [长度高字节] [ECG数据...] [校验和]
|
||||
```
|
||||
|
||||
### 自定义数据:
|
||||
支持十六进制格式,如:`01 02 03 04 05`
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **先测试连接**:确保能连接到您的电脑
|
||||
2. **再测试发送**:发送各种测试数据验证连接
|
||||
3. **最后测试接收**:验证数据接收和图表显示
|
||||
4. **查看日志**:通过Logcat监控详细过程
|
||||
|
||||
## 常见问题解决
|
||||
|
||||
### 连接失败:
|
||||
- 检查蓝牙权限
|
||||
- 确认MAC地址正确
|
||||
- 确保设备在范围内
|
||||
|
||||
### 数据不显示:
|
||||
- 点击"启动程序"生成测试数据
|
||||
- 检查连接状态
|
||||
- 查看日志错误信息
|
||||
|
||||
### 图表空白:
|
||||
- 检查图表容器可见性
|
||||
- 确认数据回调正常
|
||||
- 尝试重新生成测试数据
|
||||
|
||||
## 下一步操作
|
||||
|
||||
1. 编译并运行应用
|
||||
2. 按照测试步骤进行连接测试
|
||||
3. 验证数据收发功能
|
||||
4. 检查图表显示效果
|
||||
5. 查看日志确认各环节正常
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果遇到问题,请:
|
||||
1. 查看Android Studio的Logcat日志
|
||||
2. 检查权限设置
|
||||
3. 确认设备MAC地址
|
||||
4. 验证蓝牙功能正常
|
||||
91
编译错误修复总结.md
91
编译错误修复总结.md
|
|
@ -1,91 +0,0 @@
|
|||
# 编译错误修复总结
|
||||
|
||||
## 问题描述
|
||||
在之前的代码中出现了多个编译错误,导致应用无法正常构建。
|
||||
|
||||
## 修复的错误
|
||||
|
||||
### 1. 变量名冲突
|
||||
**问题**: `bluetoothManager` 变量名与类名冲突
|
||||
**修复**: 将变量名改为 `bluetoothManagerService`
|
||||
|
||||
```kotlin
|
||||
// 修复前
|
||||
private var bluetoothManager: BluetoothManager? = null
|
||||
|
||||
// 修复后
|
||||
private var bluetoothManagerService: android.bluetooth.BluetoothManager? = null
|
||||
```
|
||||
|
||||
### 2. 未定义的引用
|
||||
**问题**: 多个回调和方法未定义
|
||||
**修复**: 添加了完整的回调定义
|
||||
|
||||
- `bleScanCallback` - BLE扫描回调
|
||||
- `registerClassicScanReceiver()` - 注册传统蓝牙扫描接收器
|
||||
- `unregisterClassicScanReceiver()` - 注销传统蓝牙扫描接收器
|
||||
|
||||
### 3. 语法错误
|
||||
**问题**: `override` 修饰符使用错误
|
||||
**修复**: 正确使用 `override` 修饰符在回调对象中
|
||||
|
||||
### 4. 缺少闭合大括号
|
||||
**问题**: 文件结构不完整
|
||||
**修复**: 补充完整的类结构
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 编译成功
|
||||
- 所有编译错误已修复
|
||||
- 应用可以正常构建
|
||||
- 只有一些关于已弃用API的警告(不影响功能)
|
||||
|
||||
### ⚠️ 警告信息
|
||||
以下API已被弃用,但功能正常:
|
||||
- `characteristic.value` - 特征值设置
|
||||
- `gatt.writeCharacteristic()` - 写入特征
|
||||
- `intent.getParcelableExtra()` - 获取Parcelable数据
|
||||
|
||||
## 功能验证
|
||||
|
||||
### 已实现的功能
|
||||
1. **蓝牙初始化** - 自动检测蓝牙适配器
|
||||
2. **设备扫描** - 支持BLE和传统蓝牙扫描
|
||||
3. **设备连接** - 支持直接MAC地址连接
|
||||
4. **服务发现** - 自动发现蓝牙服务和特征
|
||||
5. **数据收发** - 支持发送各种类型的指令
|
||||
6. **状态监控** - 详细的连接状态显示
|
||||
|
||||
### 测试准备
|
||||
现在可以测试连接目标手机:
|
||||
- **MAC地址**: `A4:C3:37:86:9F:73`
|
||||
- **连接方式**: 长按"连接蓝牙"按钮
|
||||
- **状态显示**: 详细的状态信息和调试信息
|
||||
|
||||
## 下一步操作
|
||||
|
||||
1. **运行应用** - 在Android Studio中运行应用
|
||||
2. **测试连接** - 尝试连接目标手机
|
||||
3. **观察日志** - 查看Logcat中的详细信息
|
||||
4. **功能验证** - 测试数据收发功能
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 蓝牙协议支持
|
||||
- **BLE (Bluetooth Low Energy)** - 低功耗蓝牙
|
||||
- **传统蓝牙** - 经典蓝牙协议
|
||||
- **自动检测** - 根据设备能力自动选择协议
|
||||
|
||||
### 设备兼容性
|
||||
- **多种UUID支持** - 支持多种ECG设备UUID
|
||||
- **智能检测** - 自动检测设备服务和特征
|
||||
- **错误处理** - 完善的错误处理和用户提示
|
||||
|
||||
### 调试功能
|
||||
- **详细日志** - 完整的操作日志记录
|
||||
- **状态显示** - 实时的状态信息更新
|
||||
- **错误提示** - 友好的错误信息显示
|
||||
|
||||
## 总结
|
||||
|
||||
所有编译错误已成功修复,应用现在可以正常构建和运行。代码结构完整,功能齐全,可以开始进行蓝牙连接测试。
|
||||
217
蓝牙扫描故障排除指南.md
217
蓝牙扫描故障排除指南.md
|
|
@ -1,217 +0,0 @@
|
|||
# 蓝牙扫描故障排除指南
|
||||
|
||||
## 问题描述
|
||||
应用无法扫描到目标设备,需要诊断和解决扫描问题。
|
||||
|
||||
## 🔍 常见原因分析
|
||||
|
||||
### 1. 权限问题
|
||||
**症状**: 扫描无结果,日志显示权限错误
|
||||
**检查项目**:
|
||||
- [ ] `BLUETOOTH_SCAN` 权限已授予
|
||||
- [ ] `BLUETOOTH_CONNECT` 权限已授予
|
||||
- [ ] `ACCESS_FINE_LOCATION` 权限已授予
|
||||
- [ ] Android 12+ 需要额外权限
|
||||
|
||||
**解决方案**:
|
||||
1. 进入应用设置 → 权限
|
||||
2. 确保所有蓝牙相关权限已开启
|
||||
3. 重启应用
|
||||
|
||||
### 2. 目标设备设置问题
|
||||
**症状**: 扫描到其他设备但找不到目标设备
|
||||
**检查项目**:
|
||||
- [ ] 目标设备蓝牙已开启
|
||||
- [ ] 设置为"可发现"或"始终可见"
|
||||
- [ ] 未与其他设备连接
|
||||
- [ ] 设备在扫描范围内(10米内)
|
||||
|
||||
**解决方案**:
|
||||
1. 打开目标设备设置
|
||||
2. 进入蓝牙设置
|
||||
3. 确保蓝牙开启且可发现
|
||||
4. 断开其他连接
|
||||
|
||||
### 3. 扫描范围问题
|
||||
**症状**: 有时能找到设备,有时找不到
|
||||
**检查项目**:
|
||||
- [ ] 设备距离在10米内
|
||||
- [ ] 无金属屏蔽物
|
||||
- [ ] 远离其他蓝牙设备
|
||||
- [ ] 无WiFi干扰
|
||||
|
||||
**解决方案**:
|
||||
1. 将设备靠近(1-2米内)
|
||||
2. 移除金属物品
|
||||
3. 关闭其他蓝牙设备
|
||||
4. 尝试不同位置
|
||||
|
||||
### 4. 系统蓝牙服务问题
|
||||
**症状**: 扫描完全无结果
|
||||
**检查项目**:
|
||||
- [ ] 系统蓝牙服务正常
|
||||
- [ ] 蓝牙适配器工作正常
|
||||
- [ ] 无其他应用占用蓝牙
|
||||
|
||||
**解决方案**:
|
||||
1. 重启蓝牙服务
|
||||
2. 重启设备
|
||||
3. 检查系统蓝牙设置
|
||||
|
||||
## 🛠️ 增强的扫描功能
|
||||
|
||||
### 新增功能特性:
|
||||
1. **详细状态提示** - 显示扫描进度和注意事项
|
||||
2. **延长扫描时间** - 从10秒增加到15秒
|
||||
3. **智能错误处理** - BLE失败自动切换到传统蓝牙
|
||||
4. **设备信息显示** - 显示设备名称、地址、信号强度
|
||||
5. **目标设备识别** - 自动识别目标设备并提供连接建议
|
||||
|
||||
### 扫描流程:
|
||||
```
|
||||
开始扫描 → 权限检查 → BLE扫描(15秒) → 失败重试 → 传统蓝牙(15秒) → 结果汇总
|
||||
```
|
||||
|
||||
## 📋 测试步骤
|
||||
|
||||
### 第一步:基础检查
|
||||
1. **确认应用权限**
|
||||
- 进入应用设置 → 权限
|
||||
- 确保蓝牙和位置权限已开启
|
||||
|
||||
2. **检查系统蓝牙**
|
||||
- 进入系统设置 → 蓝牙
|
||||
- 确保蓝牙已开启
|
||||
|
||||
3. **检查目标设备**
|
||||
- 确保目标设备蓝牙开启
|
||||
- 设置为可发现模式
|
||||
|
||||
### 第二步:系统测试
|
||||
1. **使用系统蓝牙扫描**
|
||||
- 在系统蓝牙设置中扫描
|
||||
- 查看是否能发现目标设备
|
||||
- 记录扫描结果
|
||||
|
||||
2. **测试设备可见性**
|
||||
- 尝试手动配对
|
||||
- 确认设备可以被发现
|
||||
|
||||
### 第三步:应用测试
|
||||
1. **启动应用扫描**
|
||||
- 点击"连接蓝牙"按钮
|
||||
- 观察扫描过程
|
||||
- 查看状态信息
|
||||
|
||||
2. **观察扫描结果**
|
||||
- 查看发现的设备列表
|
||||
- 检查设备信息显示
|
||||
- 记录扫描时间
|
||||
|
||||
### 第四步:日志分析
|
||||
1. **查看应用日志**
|
||||
- 打开Android Studio Logcat
|
||||
- 过滤蓝牙相关日志
|
||||
- 分析扫描过程
|
||||
|
||||
2. **检查错误信息**
|
||||
- 查看权限错误
|
||||
- 查看扫描失败原因
|
||||
- 记录错误代码
|
||||
|
||||
## 🔧 调试技巧
|
||||
|
||||
### 1. 使用系统蓝牙验证
|
||||
```bash
|
||||
# 在系统蓝牙设置中手动扫描
|
||||
# 确认目标设备是否可见
|
||||
# 测试手动配对是否成功
|
||||
```
|
||||
|
||||
### 2. 检查设备信息
|
||||
- **目标设备型号和Android版本**
|
||||
- **蓝牙芯片类型**
|
||||
- **支持的蓝牙协议**
|
||||
- **设备MAC地址**
|
||||
|
||||
### 3. 环境测试
|
||||
- **尝试不同位置**
|
||||
- **检查环境干扰**
|
||||
- **测试不同距离**
|
||||
- **移除金属物品**
|
||||
|
||||
### 4. 应用调试
|
||||
- **查看详细日志**
|
||||
- **观察状态变化**
|
||||
- **记录错误信息**
|
||||
- **分析扫描模式**
|
||||
|
||||
## 📊 常见错误码及解决方案
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| SCAN_FAILED_ALREADY_STARTED | 扫描已在进行中 | 等待当前扫描完成 |
|
||||
| SCAN_FAILED_APPLICATION_REGISTRATION_FAILED | 应用注册失败 | 重启应用或设备 |
|
||||
| SCAN_FAILED_FEATURE_UNSUPPORTED | 设备不支持BLE | 使用传统蓝牙扫描 |
|
||||
| SCAN_FAILED_INTERNAL_ERROR | 内部错误 | 重启蓝牙服务 |
|
||||
| SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES | 硬件资源不足 | 关闭其他蓝牙应用 |
|
||||
|
||||
## 🎯 针对目标设备的特殊检查
|
||||
|
||||
### 目标设备: `A4:C3:37:86:9F:73`
|
||||
|
||||
1. **设备信息确认**
|
||||
- 确认MAC地址正确
|
||||
- 检查设备型号
|
||||
- 确认Android版本
|
||||
|
||||
2. **蓝牙设置检查**
|
||||
- 确保蓝牙开启
|
||||
- 设置为可发现模式
|
||||
- 断开其他连接
|
||||
|
||||
3. **连接测试**
|
||||
- 使用系统蓝牙测试连接
|
||||
- 确认设备可以被发现
|
||||
- 测试手动配对
|
||||
|
||||
## 📝 故障排除清单
|
||||
|
||||
### 扫描前检查:
|
||||
- [ ] 应用权限已授予
|
||||
- [ ] 系统蓝牙已开启
|
||||
- [ ] 目标设备蓝牙已开启
|
||||
- [ ] 目标设备设置为可发现
|
||||
- [ ] 设备距离在10米内
|
||||
- [ ] 无其他蓝牙连接
|
||||
|
||||
### 扫描中观察:
|
||||
- [ ] 扫描状态信息显示
|
||||
- [ ] 发现设备列表更新
|
||||
- [ ] 目标设备是否出现
|
||||
- [ ] 扫描时间是否足够
|
||||
- [ ] 错误信息提示
|
||||
|
||||
### 扫描后分析:
|
||||
- [ ] 扫描结果汇总
|
||||
- [ ] 发现的设备数量
|
||||
- [ ] 目标设备是否找到
|
||||
- [ ] 设备信息是否完整
|
||||
- [ ] 连接建议是否提供
|
||||
|
||||
## 🚀 下一步操作
|
||||
|
||||
1. **按照上述步骤逐一检查**
|
||||
2. **使用系统蓝牙设置测试**
|
||||
3. **记录所有测试结果**
|
||||
4. **如果问题持续,提供详细设备信息**
|
||||
|
||||
## 📞 需要提供的信息
|
||||
|
||||
如果问题仍然存在,请提供:
|
||||
1. **目标设备型号和Android版本**
|
||||
2. **系统蓝牙设置测试结果**
|
||||
3. **应用扫描日志**
|
||||
4. **设备环境信息**
|
||||
5. **详细的测试步骤和结果**
|
||||
6. **错误信息和状态码**
|
||||
177
蓝牙权限配置指南.md
177
蓝牙权限配置指南.md
|
|
@ -1,177 +0,0 @@
|
|||
# 蓝牙权限配置完整指南
|
||||
|
||||
## 📋 权限配置概述
|
||||
|
||||
本应用已配置完整的蓝牙权限支持,包括:
|
||||
- **Android 11及以下**:需要位置权限进行蓝牙扫描
|
||||
- **Android 12+**:使用新的蓝牙权限模型,无需位置权限
|
||||
|
||||
## 🔧 AndroidManifest.xml 配置
|
||||
|
||||
### 基础蓝牙权限
|
||||
```xml
|
||||
<!-- 基础蓝牙权限 (Android 11及以下) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
|
||||
<!-- Android 12+ 新蓝牙权限 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
```
|
||||
|
||||
### 位置权限(仅Android 11及以下需要)
|
||||
```xml
|
||||
<!-- 位置权限 (Android 11及以下需要) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
```
|
||||
|
||||
### Android 12+ 特殊配置
|
||||
```xml
|
||||
<!-- Android 12+ 蓝牙扫描权限 (不用于定位) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
```
|
||||
|
||||
## 📱 运行时权限请求
|
||||
|
||||
### 动态权限检查
|
||||
应用会根据Android版本自动确定所需权限:
|
||||
|
||||
```kotlin
|
||||
// Android 12+ 使用新的蓝牙权限
|
||||
private val REQUIRED_PERMISSIONS: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
)
|
||||
} else {
|
||||
// Android 11及以下需要位置权限
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 权限状态显示
|
||||
应用会显示详细的权限状态:
|
||||
- ✅ 已授予权限
|
||||
- ❌ 缺少权限
|
||||
- 💡 权限请求提示
|
||||
|
||||
## 🔍 权限检查流程
|
||||
|
||||
### 1. 应用启动时检查
|
||||
- 显示当前权限状态
|
||||
- 提示用户授予必要权限
|
||||
|
||||
### 2. 蓝牙操作前检查
|
||||
- 自动检查所需权限
|
||||
- 如果缺少权限,自动请求
|
||||
- 显示权限请求结果
|
||||
|
||||
### 3. 权限结果处理
|
||||
- 显示已授予和被拒绝的权限
|
||||
- 提供手动设置指导
|
||||
- 根据权限状态决定是否继续操作
|
||||
|
||||
## 🚨 常见权限问题
|
||||
|
||||
### 1. 权限被拒绝
|
||||
**症状**: 扫描无结果,显示"权限被拒绝"
|
||||
**解决方案**:
|
||||
1. 进入应用设置 → 权限
|
||||
2. 手动开启蓝牙和位置权限
|
||||
3. 重启应用
|
||||
|
||||
### 2. Android 12+ 权限问题
|
||||
**症状**: 新设备上扫描失败
|
||||
**解决方案**:
|
||||
1. 确保使用新的蓝牙权限
|
||||
2. 检查`neverForLocation`标志
|
||||
3. 确认权限已正确授予
|
||||
|
||||
### 3. 位置权限问题
|
||||
**症状**: Android 11及以下设备扫描失败
|
||||
**解决方案**:
|
||||
1. 确保位置权限已授予
|
||||
2. 检查GPS是否开启(某些厂商系统需要)
|
||||
3. 重启蓝牙服务
|
||||
|
||||
## 📊 权限状态检查
|
||||
|
||||
### 应用内权限状态显示
|
||||
```
|
||||
权限状态:
|
||||
BLUETOOTH_SCAN: ✓
|
||||
BLUETOOTH_CONNECT: ✓
|
||||
ACCESS_FINE_LOCATION: ✓
|
||||
ACCESS_COARSE_LOCATION: ✓
|
||||
```
|
||||
|
||||
### 系统设置检查
|
||||
1. **设置 → 应用 → 本应用 → 权限**
|
||||
2. **设置 → 隐私 → 位置信息**
|
||||
3. **设置 → 蓝牙**
|
||||
|
||||
## 🛠️ 调试权限问题
|
||||
|
||||
### 1. 检查权限声明
|
||||
```bash
|
||||
# 确认AndroidManifest.xml中的权限声明正确
|
||||
# 检查maxSdkVersion和usesPermissionFlags
|
||||
```
|
||||
|
||||
### 2. 检查运行时权限
|
||||
```kotlin
|
||||
// 在代码中检查权限状态
|
||||
ContextCompat.checkSelfPermission(context, permission)
|
||||
```
|
||||
|
||||
### 3. 查看权限请求结果
|
||||
```kotlin
|
||||
// 在onRequestPermissionsResult中处理结果
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray)
|
||||
```
|
||||
|
||||
## 📝 权限配置清单
|
||||
|
||||
### AndroidManifest.xml 检查项
|
||||
- [ ] 基础蓝牙权限已声明
|
||||
- [ ] Android 12+ 新权限已声明
|
||||
- [ ] 位置权限限制到Android 11及以下
|
||||
- [ ] `neverForLocation`标志已设置
|
||||
- [ ] 蓝牙功能特性已声明
|
||||
|
||||
### 代码权限检查项
|
||||
- [ ] 动态权限检查已实现
|
||||
- [ ] 权限请求已处理
|
||||
- [ ] 权限结果已处理
|
||||
- [ ] 权限状态显示已实现
|
||||
|
||||
### 测试检查项
|
||||
- [ ] Android 11及以下设备测试通过
|
||||
- [ ] Android 12+ 设备测试通过
|
||||
- [ ] 权限拒绝场景测试通过
|
||||
- [ ] 权限授予后功能正常
|
||||
|
||||
## 🎯 针对目标设备的权限检查
|
||||
|
||||
### 目标设备: `A4:C3:37:86:9F:73`
|
||||
|
||||
1. **确认设备Android版本**
|
||||
2. **检查对应权限是否已授予**
|
||||
3. **验证权限配置是否正确**
|
||||
4. **测试扫描功能是否正常**
|
||||
|
||||
## 🚀 下一步操作
|
||||
|
||||
1. **重新编译应用**
|
||||
2. **在目标设备上测试权限**
|
||||
3. **确认权限授予后扫描正常**
|
||||
4. **如果仍有问题,检查设备特定设置**
|
||||
183
蓝牙调试指南.md
183
蓝牙调试指南.md
|
|
@ -1,183 +0,0 @@
|
|||
# 蓝牙调试指南 - SerialTest连接问题
|
||||
|
||||
## 问题描述
|
||||
- 电脑端:SerialTest作为蓝牙服务器运行
|
||||
- 手机端:Android应用作为客户端
|
||||
- 问题:客户端找不到服务器设备
|
||||
|
||||
## 第一步:检查手机蓝牙设置
|
||||
|
||||
### 1.1 确保蓝牙可见性
|
||||
1. **打开手机设置** → **蓝牙**
|
||||
2. **确保蓝牙已开启**
|
||||
3. **设置可见性**:
|
||||
- 点击"更多设置"或"高级设置"
|
||||
- 找到"可见性"或"可发现性"选项
|
||||
- 设置为"始终可见"或"可发现"
|
||||
|
||||
### 1.2 检查配对状态
|
||||
1. **查看已配对设备列表**
|
||||
2. **如果看到您的电脑设备**:
|
||||
- 点击设备名称
|
||||
- 选择"取消配对"
|
||||
- 重新配对
|
||||
|
||||
### 1.3 重置蓝牙设置
|
||||
1. **清除蓝牙缓存**:
|
||||
- 设置 → 应用管理 → 蓝牙
|
||||
- 清除数据和缓存
|
||||
2. **重启蓝牙**:
|
||||
- 关闭蓝牙
|
||||
- 等待10秒
|
||||
- 重新开启蓝牙
|
||||
|
||||
## 第二步:检查电脑端SerialTest设置
|
||||
|
||||
### 2.1 SerialTest配置
|
||||
1. **确保SerialTest正确配置**:
|
||||
- 选择正确的COM端口
|
||||
- 设置波特率(通常9600或115200)
|
||||
- 启用蓝牙服务器模式
|
||||
|
||||
### 2.2 电脑蓝牙设置
|
||||
1. **检查电脑蓝牙状态**:
|
||||
- 确保蓝牙已开启
|
||||
- 设置为"可发现"模式
|
||||
2. **查看蓝牙设备**:
|
||||
- 控制面板 → 设备和打印机
|
||||
- 确认蓝牙适配器正常工作
|
||||
|
||||
## 第三步:使用应用进行调试
|
||||
|
||||
### 3.1 扫描测试
|
||||
1. **启动Android应用**
|
||||
2. **点击"连接蓝牙"按钮**(普通点击,不是长按)
|
||||
3. **观察扫描结果**:
|
||||
- 查看是否发现您的电脑设备
|
||||
- 记录设备名称和MAC地址
|
||||
|
||||
### 3.2 直接连接测试
|
||||
1. **长按"连接蓝牙"按钮**
|
||||
2. **输入电脑MAC地址**:`60:E9:AA:30:8B:0A`
|
||||
3. **点击连接**
|
||||
4. **观察连接状态**
|
||||
|
||||
### 3.3 查看详细日志
|
||||
在Android Studio的Logcat中查看:
|
||||
- 过滤标签:`BluetoothManager`
|
||||
- 查找扫描和连接相关的日志
|
||||
|
||||
## 第四步:常见解决方案
|
||||
|
||||
### 4.1 如果扫描不到设备
|
||||
1. **重启蓝牙服务**:
|
||||
- 电脑:重启蓝牙适配器
|
||||
- 手机:重启蓝牙
|
||||
2. **检查距离**:确保设备在10米范围内
|
||||
3. **检查干扰**:远离其他蓝牙设备
|
||||
|
||||
### 4.2 如果连接失败
|
||||
1. **重新配对设备**:
|
||||
- 在手机蓝牙设置中删除电脑设备
|
||||
- 在电脑蓝牙设置中删除手机设备
|
||||
- 重新配对
|
||||
2. **检查SerialTest状态**:
|
||||
- 确保SerialTest正在运行
|
||||
- 检查是否有错误信息
|
||||
|
||||
### 4.3 如果连接成功但无法通信
|
||||
1. **检查SerialTest配置**:
|
||||
- 确认波特率设置
|
||||
- 确认数据格式设置
|
||||
2. **测试数据发送**:
|
||||
- 使用应用发送测试数据
|
||||
- 在SerialTest中查看是否收到数据
|
||||
|
||||
## 第五步:高级调试
|
||||
|
||||
### 5.1 使用系统蓝牙设置
|
||||
1. **在手机蓝牙设置中手动连接**:
|
||||
- 扫描设备
|
||||
- 选择您的电脑设备
|
||||
- 输入配对码(通常是0000或1234)
|
||||
|
||||
### 5.2 检查蓝牙协议
|
||||
1. **确认SerialTest使用的协议**:
|
||||
- SPP(串口协议)
|
||||
- RFCOMM
|
||||
- 其他协议
|
||||
2. **确认应用支持的协议**
|
||||
|
||||
### 5.3 使用其他蓝牙工具测试
|
||||
1. **下载蓝牙调试工具**:
|
||||
- nRF Connect(推荐)
|
||||
- Bluetooth Scanner
|
||||
2. **测试连接**:
|
||||
- 扫描设备
|
||||
- 尝试连接
|
||||
- 查看服务列表
|
||||
|
||||
## 第六步:应用调试功能
|
||||
|
||||
### 6.1 增强扫描功能
|
||||
应用现在支持:
|
||||
- **BLE扫描**:低功耗蓝牙扫描
|
||||
- **传统蓝牙扫描**:经典蓝牙扫描
|
||||
- **智能检测**:自动检测设备类型
|
||||
|
||||
### 6.2 连接状态监控
|
||||
应用会显示:
|
||||
- ✅ **连接成功**:设备已连接
|
||||
- ❌ **连接失败**:详细的错误信息
|
||||
- 🔍 **服务发现**:发现的服务和特征
|
||||
- 📡 **数据通道**:通信通道状态
|
||||
|
||||
## 第七步:测试步骤
|
||||
|
||||
### 7.1 基础测试
|
||||
1. **启动SerialTest** → 配置服务器
|
||||
2. **启动Android应用** → 扫描设备
|
||||
3. **连接设备** → 观察状态
|
||||
4. **发送测试数据** → 验证通信
|
||||
|
||||
### 7.2 数据通信测试
|
||||
1. **发送ECG测试数据**
|
||||
2. **发送心跳包**
|
||||
3. **发送设备信息查询**
|
||||
4. **观察SerialTest接收情况**
|
||||
|
||||
## 常见错误及解决方案
|
||||
|
||||
### 错误1:扫描不到设备
|
||||
**解决方案**:
|
||||
- 检查设备可见性
|
||||
- 重启蓝牙服务
|
||||
- 检查距离和干扰
|
||||
|
||||
### 错误2:连接超时
|
||||
**解决方案**:
|
||||
- 重新配对设备
|
||||
- 检查SerialTest状态
|
||||
- 确认MAC地址正确
|
||||
|
||||
### 错误3:服务发现失败
|
||||
**解决方案**:
|
||||
- 检查设备协议兼容性
|
||||
- 使用系统蓝牙设置连接
|
||||
- 查看详细错误日志
|
||||
|
||||
## 下一步操作
|
||||
|
||||
1. **按照上述步骤逐一检查**
|
||||
2. **记录每个步骤的结果**
|
||||
3. **查看Android Studio的Logcat日志**
|
||||
4. **如果仍有问题,提供详细的错误信息**
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果问题仍然存在,请提供:
|
||||
1. **手机型号和Android版本**
|
||||
2. **电脑操作系统版本**
|
||||
3. **SerialTest版本和配置**
|
||||
4. **详细的错误日志**
|
||||
5. **扫描和连接过程的截图**
|
||||
96
蓝牙连接测试说明.md
96
蓝牙连接测试说明.md
|
|
@ -1,96 +0,0 @@
|
|||
# 蓝牙连接测试说明
|
||||
|
||||
## 数据流概述
|
||||
|
||||
您的应用数据流如下:
|
||||
|
||||
1. **蓝牙连接** → **数据接收** → **数据解析** → **信号处理** → **图表显示**
|
||||
|
||||
### 详细流程:
|
||||
- **蓝牙管理器** (BluetoothManager) 负责设备连接和数据接收
|
||||
- **数据管理器** (DataManager) 负责数据解析和信号处理
|
||||
- **实时数据回调** 将处理后的数据发送到图表显示
|
||||
- **ECG图表视图** 实时显示ECG波形
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 直接连接测试
|
||||
1. 启动应用
|
||||
2. **长按"连接蓝牙"按钮** → 弹出直接连接对话框
|
||||
3. 输入您的电脑MAC地址:`60:E9:AA:30:8B:0A`(使用冒号分隔符)
|
||||
4. 点击"连接"按钮
|
||||
5. 观察连接状态
|
||||
|
||||
### 2. 数据收发测试
|
||||
1. 连接成功后,**长按"发送指令"按钮** → 弹出测试数据对话框
|
||||
2. 选择测试选项:
|
||||
- **发送ECG测试数据**:发送模拟ECG数据包
|
||||
- **发送心跳包**:发送简单的心跳数据
|
||||
- **发送设备信息查询**:查询设备信息
|
||||
- **发送自定义测试数据**:发送自定义十六进制数据
|
||||
|
||||
### 3. 图表显示测试
|
||||
1. 点击"启动程序"按钮 → 立即生成测试数据并显示图表
|
||||
2. 长按"启动程序"按钮 → 生成更复杂的ECG波形测试
|
||||
3. 观察ECG双视图的实时更新
|
||||
|
||||
## 测试功能说明
|
||||
|
||||
### 蓝牙连接功能
|
||||
- **扫描设备**:自动扫描附近的蓝牙设备
|
||||
- **直接连接**:通过MAC地址直接连接指定设备
|
||||
- **连接状态**:实时显示连接状态和错误信息
|
||||
|
||||
### 数据发送功能
|
||||
- **ECG测试数据**:生成符合ECG格式的模拟数据包
|
||||
- **心跳包**:简单的连接测试数据
|
||||
- **自定义数据**:支持十六进制格式的自定义数据
|
||||
|
||||
### 数据接收功能
|
||||
- **实时接收**:自动接收蓝牙数据
|
||||
- **数据解析**:使用原生解析器解析数据包
|
||||
- **信号处理**:应用滤波和信号处理算法
|
||||
- **实时显示**:立即更新图表显示
|
||||
|
||||
## 调试信息
|
||||
|
||||
应用会在日志中输出详细的调试信息:
|
||||
- 蓝牙连接状态
|
||||
- 数据接收情况
|
||||
- 数据解析结果
|
||||
- 信号处理进度
|
||||
- 图表更新状态
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 连接失败
|
||||
1. 检查蓝牙权限是否已授予
|
||||
2. 确认设备MAC地址正确(使用冒号分隔符,如:60:E9:AA:30:8B:0A)
|
||||
3. 确保目标设备在范围内且可发现
|
||||
|
||||
### 数据接收问题
|
||||
1. 检查连接状态
|
||||
2. 查看日志中的错误信息
|
||||
3. 尝试发送测试数据验证连接
|
||||
|
||||
### 图表不显示
|
||||
1. 点击"启动程序"按钮生成测试数据
|
||||
2. 检查图表容器是否可见
|
||||
3. 查看数据回调是否正常
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **先测试连接**:确保能成功连接到您的电脑
|
||||
2. **再测试发送**:发送各种测试数据验证连接稳定性
|
||||
3. **最后测试接收**:验证数据接收和图表显示功能
|
||||
4. **查看日志**:通过Android Studio的Logcat查看详细调试信息
|
||||
|
||||
## 数据格式
|
||||
|
||||
### ECG测试数据包格式
|
||||
```
|
||||
AA 55 01 [长度低字节] [长度高字节] [ECG数据...] [校验和]
|
||||
```
|
||||
|
||||
### 自定义数据格式
|
||||
支持十六进制格式,如:`01 02 03 04 05`
|
||||
98
连接状态显示增强.md
98
连接状态显示增强.md
|
|
@ -1,98 +0,0 @@
|
|||
# 连接状态显示增强
|
||||
|
||||
## 连接成功提示
|
||||
|
||||
现在当蓝牙设备连接成功时,应用会显示详细的连接状态信息:
|
||||
|
||||
### 主要提示信息:
|
||||
- ✅ **设备已连接**: [设备名称/地址]
|
||||
- 🎉 **连接成功!设备信息:**
|
||||
- 设备名称: [设备名称]
|
||||
- 设备地址: [MAC地址]
|
||||
- 设备类型: [设备类型]
|
||||
- 📡 **数据通道已建立,可以开始收发数据**
|
||||
- 📊 **ECG图表已准备就绪,请点击'发送指令'按钮开始接收数据**
|
||||
|
||||
### 服务发现信息:
|
||||
- 🔍 **服务发现成功**
|
||||
- 📋 **发现 X 个服务**
|
||||
- 📡 **数据通道已建立,可以发送指令开始接收数据**
|
||||
- 💡 **提示: 长按'发送指令'按钮可以发送测试数据**
|
||||
|
||||
## 连接状态变化
|
||||
|
||||
### 连接成功时:
|
||||
1. **按钮状态变化**:
|
||||
- "连接蓝牙" → "断开蓝牙"
|
||||
- 按钮颜色变为红色 (#F44336)
|
||||
- "发送指令"按钮启用并变为蓝色
|
||||
|
||||
2. **图表显示**:
|
||||
- ECG图表容器自动显示
|
||||
- 准备接收数据
|
||||
|
||||
3. **状态信息**:
|
||||
- 显示详细的设备信息
|
||||
- 提供下一步操作提示
|
||||
|
||||
### 连接断开时:
|
||||
1. **按钮状态变化**:
|
||||
- "断开蓝牙" → "连接蓝牙"
|
||||
- 按钮颜色变为绿色 (#4CAF50)
|
||||
- "发送指令"按钮禁用并变为灰色
|
||||
|
||||
2. **状态信息**:
|
||||
- 显示断开连接提示
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 连接测试
|
||||
1. 启动应用
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. 输入MAC地址:`60:E9:AA:30:8B:0A`
|
||||
4. 点击"连接"
|
||||
5. **观察连接成功提示**:
|
||||
- ✅ 设备已连接
|
||||
- 🎉 连接成功!设备信息
|
||||
- 📡 数据通道已建立
|
||||
|
||||
### 2. 验证连接状态
|
||||
1. 检查按钮状态变化
|
||||
2. 查看状态信息区域的详细提示
|
||||
3. 确认ECG图表容器已显示
|
||||
|
||||
### 3. 测试数据收发
|
||||
1. **长按"发送指令"按钮**发送测试数据
|
||||
2. 观察数据接收状态
|
||||
3. 查看图表显示效果
|
||||
|
||||
## 调试信息
|
||||
|
||||
### 日志标签:
|
||||
- `MainActivity`: 主要操作日志
|
||||
- `BluetoothManager`: 蓝牙连接日志
|
||||
|
||||
### 状态显示:
|
||||
- 连接状态实时更新
|
||||
- 设备信息详细显示
|
||||
- 操作提示清晰明确
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 连接成功但没有显示提示:
|
||||
1. 检查状态信息区域是否可见
|
||||
2. 查看日志确认回调是否正常
|
||||
3. 确认UI线程更新是否成功
|
||||
|
||||
### 设备信息不完整:
|
||||
1. 某些设备可能不提供完整信息
|
||||
2. 应用会显示可用的信息
|
||||
3. 不影响连接功能
|
||||
|
||||
## 下一步操作
|
||||
|
||||
连接成功后,您可以:
|
||||
1. **发送测试数据**:长按"发送指令"按钮
|
||||
2. **查看图表显示**:点击"启动程序"按钮
|
||||
3. **测试数据接收**:观察实时数据流
|
||||
4. **应用信号处理**:测试陷波滤波器等功能
|
||||
164
连接超时故障排除指南.md
164
连接超时故障排除指南.md
|
|
@ -1,164 +0,0 @@
|
|||
# 连接超时故障排除指南
|
||||
|
||||
## 问题描述
|
||||
应用尝试连接到目标设备 `A4:C3:37:86:9F:73` 时出现连接超时错误。
|
||||
|
||||
## 日志分析
|
||||
|
||||
### 关键日志信息
|
||||
```
|
||||
尝试直接连接到设备: A4:C3:37:86:9F:73
|
||||
on_create_connection_timeout, address: a4:c3:37:86:9f:73
|
||||
Connection failed le remote:a4:c3:37:86:9f:73
|
||||
status=147 clientIf=16 connected=false
|
||||
```
|
||||
|
||||
### 问题分析
|
||||
- **连接启动成功** - 应用成功发起连接请求
|
||||
- **30秒超时** - 连接在30秒后超时
|
||||
- **状态码147** - 表示连接失败
|
||||
- **LE连接失败** - 低功耗蓝牙连接失败
|
||||
|
||||
## 可能原因及解决方案
|
||||
|
||||
### 1. 目标设备蓝牙设置问题
|
||||
|
||||
#### 检查项目:
|
||||
- [ ] 蓝牙已开启
|
||||
- [ ] 设置为"可发现"模式
|
||||
- [ ] 未与其他设备连接
|
||||
- [ ] 蓝牙服务正常运行
|
||||
|
||||
#### 解决步骤:
|
||||
1. **打开目标手机设置**
|
||||
2. **进入蓝牙设置**
|
||||
3. **确保蓝牙已开启**
|
||||
4. **设置为"始终可见"或"可发现"**
|
||||
5. **断开其他蓝牙连接**
|
||||
|
||||
### 2. 设备距离和干扰
|
||||
|
||||
#### 检查项目:
|
||||
- [ ] 设备距离在10米内
|
||||
- [ ] 无金属屏蔽物
|
||||
- [ ] 远离其他蓝牙设备
|
||||
- [ ] 无WiFi干扰
|
||||
|
||||
#### 解决步骤:
|
||||
1. **将两台设备靠近**(1-2米内)
|
||||
2. **移除金属物品**
|
||||
3. **关闭其他蓝牙设备**
|
||||
4. **尝试不同位置**
|
||||
|
||||
### 3. 系统蓝牙服务问题
|
||||
|
||||
#### 检查项目:
|
||||
- [ ] 系统蓝牙服务正常
|
||||
- [ ] 蓝牙权限已授予
|
||||
- [ ] 无其他应用占用蓝牙
|
||||
|
||||
#### 解决步骤:
|
||||
1. **重启两台设备的蓝牙**
|
||||
2. **检查应用权限**
|
||||
3. **关闭其他蓝牙应用**
|
||||
4. **重启设备**
|
||||
|
||||
### 4. 设备兼容性问题
|
||||
|
||||
#### 检查项目:
|
||||
- [ ] 目标设备支持BLE
|
||||
- [ ] 设备型号和Android版本
|
||||
- [ ] 蓝牙协议兼容性
|
||||
|
||||
#### 解决步骤:
|
||||
1. **确认设备支持BLE**
|
||||
2. **更新系统版本**
|
||||
3. **尝试传统蓝牙连接**
|
||||
4. **使用系统蓝牙设置测试**
|
||||
|
||||
## 增强的连接功能
|
||||
|
||||
### 新增功能:
|
||||
1. **详细状态提示** - 显示连接进度和注意事项
|
||||
2. **自动重试机制** - 超时后自动尝试传统蓝牙连接
|
||||
3. **错误状态码解析** - 根据状态码提供具体建议
|
||||
4. **连接超时设置** - 30秒BLE + 15秒传统蓝牙
|
||||
|
||||
### 连接流程:
|
||||
```
|
||||
开始连接 → BLE连接(30秒) → 超时重试 → 传统蓝牙(15秒) → 最终结果
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 第一步:基础检查
|
||||
1. **确认目标设备蓝牙开启**
|
||||
2. **设置为可发现模式**
|
||||
3. **断开其他连接**
|
||||
4. **将设备靠近**
|
||||
|
||||
### 第二步:系统测试
|
||||
1. **在系统蓝牙设置中扫描**
|
||||
2. **查看是否发现目标设备**
|
||||
3. **尝试手动配对**
|
||||
4. **记录配对结果**
|
||||
|
||||
### 第三步:应用测试
|
||||
1. **启动应用**
|
||||
2. **长按"连接蓝牙"按钮**
|
||||
3. **输入MAC地址:A4:C3:37:86:9F:73**
|
||||
4. **观察连接过程**
|
||||
|
||||
### 第四步:日志分析
|
||||
1. **查看应用状态信息**
|
||||
2. **检查Logcat日志**
|
||||
3. **记录错误信息**
|
||||
4. **分析失败原因**
|
||||
|
||||
## 常见错误码及含义
|
||||
|
||||
| 状态码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 0 | 正常断开 | 检查设备状态 |
|
||||
| 8 | 连接超时 | 检查设备可见性和距离 |
|
||||
| 19 | 连接被拒绝 | 检查设备是否忙 |
|
||||
| 22 | 连接失败 | 检查协议兼容性 |
|
||||
| 147 | 连接失败 | 检查设备状态和设置 |
|
||||
|
||||
## 调试建议
|
||||
|
||||
### 1. 使用系统蓝牙测试
|
||||
- 在系统蓝牙设置中手动连接
|
||||
- 验证设备是否可见和可连接
|
||||
- 确认配对是否成功
|
||||
|
||||
### 2. 检查设备信息
|
||||
- 目标设备型号和Android版本
|
||||
- 蓝牙芯片类型
|
||||
- 支持的蓝牙协议
|
||||
|
||||
### 3. 环境测试
|
||||
- 尝试不同位置
|
||||
- 检查环境干扰
|
||||
- 测试不同距离
|
||||
|
||||
### 4. 应用调试
|
||||
- 查看详细日志
|
||||
- 观察状态变化
|
||||
- 记录错误信息
|
||||
|
||||
## 下一步操作
|
||||
|
||||
1. **按照上述步骤逐一检查**
|
||||
2. **使用系统蓝牙设置测试连接**
|
||||
3. **记录所有测试结果**
|
||||
4. **如果问题持续,提供详细设备信息**
|
||||
|
||||
## 需要提供的信息
|
||||
|
||||
如果问题仍然存在,请提供:
|
||||
1. **目标设备型号和Android版本**
|
||||
2. **系统蓝牙设置测试结果**
|
||||
3. **详细的错误日志**
|
||||
4. **设备环境信息**
|
||||
5. **测试步骤和结果**
|
||||
Loading…
Reference in New Issue