Compare commits
3
Commits
5594035dc1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898eecbf76 | ||
|
|
9b1ce66f4b | ||
|
|
e1ca0d8512 |
@@ -48,3 +48,5 @@ __pycache__/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
demo/
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function ProjectsScreen() {
|
||||
const handleDeleteProject = (project: ProjectInfo) => {
|
||||
Alert.alert(
|
||||
'删除工程',
|
||||
`确定删除工程「${project.name}」?\n测线数据将保留,但与工程的关联会断开。`,
|
||||
`确定删除工程「${project.name}」?\n该工程下的所有测线和测点数据将被永久删除,不可恢复。`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
|
||||
@@ -192,6 +192,8 @@ export default function RecordsScreen() {
|
||||
await resumeSession(newSessionId, projectId);
|
||||
};
|
||||
|
||||
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
|
||||
|
||||
const handleFramePress = async (pf: PersistedFrame) => {
|
||||
if (!pf.binPath) return;
|
||||
const loaded = await BinLoader.loadBinFile(pf.binPath);
|
||||
@@ -202,7 +204,7 @@ export default function RecordsScreen() {
|
||||
adcUV: loaded.adcUV,
|
||||
accNum: pf.accNum,
|
||||
gain: pf.gain,
|
||||
sampleFreqCode: loaded.sampleFreqCode,
|
||||
sampleFreqCode,
|
||||
timestamp: pf.timestamp,
|
||||
frameId: pf.frameId,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
# TEM Receiver 用户手册
|
||||
|
||||
> 版本:1.0.0
|
||||
> 最后更新:2026-06-20
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [简介](#一简介)
|
||||
2. [安装](#二安装)
|
||||
3. [首次使用](#三首次使用)
|
||||
4. [连接设备](#四连接设备)
|
||||
5. [参数配置](#五参数配置)
|
||||
6. [数据采集](#六数据采集)
|
||||
7. [波形查看](#七波形查看)
|
||||
8. [测点管理](#八测点管理)
|
||||
9. [剖面分析](#九剖面分析)
|
||||
10. [工程管理](#十工程管理)
|
||||
11. [数据导出与分享](#十一数据导出与分享)
|
||||
12. [数据导入](#十二数据导入)
|
||||
13. [主题切换](#十三主题切换)
|
||||
14. [存储管理](#十四存储管理)
|
||||
15. [常见问题](#十五常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 一、简介
|
||||
|
||||
TEM Receiver 是六通道瞬变电磁(TEM)接收机的手机端控制软件。通过 WiFi 连接接收机设备,可在手机上完成参数配置、数据采集、波形查看、剖面分析和数据管理全部操作。
|
||||
|
||||
### 主要功能
|
||||
|
||||
- 通过 WiFi 无线连接 TEM 接收机
|
||||
- 配置采集参数(通道数、频率、增益等)
|
||||
- 单次采集和连续采集
|
||||
- 实时波形显示(支持全屏、缩放、拖拽)
|
||||
- 工程-测线-测点三级数据管理
|
||||
- 数据导出(CSV 和 .tem 格式)
|
||||
- 数据分享(微信、蓝牙、文件管理器等)
|
||||
- 自动深色/浅色主题
|
||||
- 设备状态实时监控(电流、电池、温度、GPS、SD 卡)
|
||||
|
||||
---
|
||||
|
||||
## 二、安装
|
||||
|
||||
### Android
|
||||
|
||||
1. 将 `app-release.apk` 文件传输到手机
|
||||
2. 在手机上打开 APK 文件
|
||||
3. 如提示"未知来源",在设置中允许安装
|
||||
4. 安装完成后在桌面找到「TEM Receiver」图标
|
||||
|
||||
### iOS
|
||||
|
||||
通过 TestFlight 安装(需开发者提供邀请链接)。
|
||||
|
||||
---
|
||||
|
||||
## 三、首次使用
|
||||
|
||||
首次打开 App 后:
|
||||
|
||||
1. 所有页面会显示「请先创建工程」提示
|
||||
2. 点击「前往工程页」按钮
|
||||
3. 在工程页点击右上角「+ 新建」
|
||||
4. 输入工程名称(如:项目名称 + 日期)
|
||||
5. 确认后 App 自动创建工程和第一条测线
|
||||
6. 返回波形页即可开始使用
|
||||
|
||||
> **重要**:所有采集数据必须归属于一个工程。没有工程时无法采集数据。
|
||||
|
||||
---
|
||||
|
||||
## 四、连接设备
|
||||
|
||||
### 4.1 连接 WiFi
|
||||
|
||||
1. 打开 TEM 接收机电源
|
||||
2. 手机进入系统设置 → WiFi
|
||||
3. 连接接收机的 WiFi 热点(通常名称包含设备编号)
|
||||
4. 默认 IP 地址:`192.168.4.1`,端口:`4321`
|
||||
|
||||
### 4.2 App 内连接
|
||||
|
||||
1. 点击顶部状态栏的「TCP 未连接」
|
||||
2. 弹出连接窗口,确认 IP 和端口
|
||||
3. 点击「连接」
|
||||
4. 连接成功后状态变为绿色「TCP 已连接」
|
||||
|
||||
### 4.3 连接状态说明
|
||||
|
||||
| 状态 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| 已连接 | 绿色 | 正常工作 |
|
||||
| 连接中 | 黄色 | 正在连接 |
|
||||
| 重连 | 黄色 | 断线后自动重连 |
|
||||
| 未连接 | 灰色 | 未连接设备 |
|
||||
|
||||
> App 会自动保持心跳(每 8 秒),连接断开时自动重连。
|
||||
|
||||
---
|
||||
|
||||
## 五、参数配置
|
||||
|
||||
### 5.1 打开配置面板
|
||||
|
||||
在波形页点击右下角的「⚙」设置按钮,弹出参数配置面板。
|
||||
|
||||
### 5.2 可配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 通道数 | 采集通道数量,1-6 | 3 |
|
||||
| 发射频率 | 发射机工作频率 | 16 Hz |
|
||||
| 采样频率 | ADC 采样率 | 250 kHz |
|
||||
| 采样深度 | 每通道采样点数 | 2000 |
|
||||
| 叠加次数 | 信号叠加次数,提升信噪比 | 32 |
|
||||
| 增益 | 放大倍数 | 1× |
|
||||
| 补偿电阻 | 补偿电阻值 | 12 |
|
||||
| 补偿去使能延时 | 延时参数 | 60 |
|
||||
| 源模式 | 单源/双源/三源 | 单源 A |
|
||||
| 反向叠加 | 123/456 通道反向叠加 | 关闭 |
|
||||
| 文件前缀 | SD 卡存储文件名前缀 | 当天日期 |
|
||||
|
||||
### 5.3 下发配置
|
||||
|
||||
1. 修改参数后,面板底部按钮变为蓝色「下发配置」
|
||||
2. 点击下发,等待设备回复
|
||||
3. 弹出「配置成功」表示参数已生效
|
||||
4. 弹出「配置超时」表示设备未响应,请检查连接
|
||||
|
||||
> **注意**:采集过程中不可下发配置,需先停止采集。
|
||||
|
||||
---
|
||||
|
||||
## 六、数据采集
|
||||
|
||||
### 6.1 单次采集
|
||||
|
||||
1. 确保已连接设备且已下发配置
|
||||
2. 点击波形页底部的「单次」按钮
|
||||
3. 设备完成一次采集后自动停止
|
||||
4. 波形图显示采集结果
|
||||
|
||||
### 6.2 连续采集
|
||||
|
||||
1. 点击「连续采集」按钮
|
||||
2. 设备按设定的发射频率持续采集
|
||||
3. 波形图实时更新
|
||||
4. 点击「停止」按钮结束采集
|
||||
|
||||
### 6.3 采集状态
|
||||
|
||||
| 状态 | 按钮显示 | 说明 |
|
||||
|------|---------|------|
|
||||
| 空闲 | 连续采集 / 单次 | 可以开始采集 |
|
||||
| 运行中 | 停止 | 连续采集中 |
|
||||
| 单次采集中 | 单次采集中… | 等待数据返回 |
|
||||
|
||||
### 6.4 状态信息
|
||||
|
||||
波形图下方实时显示:
|
||||
|
||||
**第一行**:源模式、发射电流(A)、电池电压(V)、温度(°C)
|
||||
|
||||
**第二行**:Roll、Pitch、Yaw 姿态角、GPS 状态、SD 卡状态
|
||||
|
||||
GPS 状态含义:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| 未定位 | GPS 未锁定 |
|
||||
| 非差分 | 普通定位 |
|
||||
| 差分 | 差分定位 |
|
||||
| 固定解 | RTK 固定解(最高精度) |
|
||||
| 浮点解 | RTK 浮点解 |
|
||||
|
||||
SD 卡状态:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| 正常 | SD 卡工作正常 |
|
||||
| 未插卡 | 未检测到 SD 卡 |
|
||||
| 异常 | SD 卡读写异常 |
|
||||
|
||||
---
|
||||
|
||||
## 七、波形查看
|
||||
|
||||
### 7.1 通道选择
|
||||
|
||||
波形图上方有 CH1-CH6 通道按钮,点击可显示/隐藏对应通道。各通道颜色:
|
||||
|
||||
- CH1 红色 / CH2 绿色 / CH3 蓝色
|
||||
- CH4 橙色 / CH5 紫色 / CH6 青色
|
||||
|
||||
### 7.2 显示模式
|
||||
|
||||
点击「LIN/LOG」按钮切换:
|
||||
|
||||
- **LIN**(线性)— 默认模式,适合查看信号波形
|
||||
- **LOG**(对数)— 适合查看大动态范围信号
|
||||
|
||||
### 7.3 全屏查看
|
||||
|
||||
1. 点击通道栏右侧的「⛶」全屏按钮
|
||||
2. 进入全屏模式,支持横屏显示
|
||||
3. 手势操作:
|
||||
- **单指拖拽** — 平移波形
|
||||
- **双指捏合** — 缩放波形
|
||||
- **双击** — 恢复原始视图
|
||||
4. 右上角缩放倍率显示(如 ×2.5)
|
||||
5. 点击「✕」或按手机返回键退出全屏
|
||||
|
||||
---
|
||||
|
||||
## 八、测点管理
|
||||
|
||||
切换到底部「测点」Tab 页。
|
||||
|
||||
### 8.1 测线选择
|
||||
|
||||
页面顶部显示当前测线信息,点击可切换到当前工程下的其他测线。
|
||||
|
||||
> **注意**:切换测线会同时切换活跃采集目标,后续采集的数据将存入所选测线。
|
||||
|
||||
### 8.2 测点列表
|
||||
|
||||
以表格形式显示当前测线的所有测点:
|
||||
|
||||
| 列 | 说明 |
|
||||
|----|------|
|
||||
| # | 测点编号 |
|
||||
| 时间 | 采集时间(UTC) |
|
||||
| GPS | 定位状态(✓/✗) |
|
||||
| 经度 | GPS 经度 |
|
||||
| 纬度 | GPS 纬度 |
|
||||
| 叠加 | 叠加次数 |
|
||||
| × | 删除按钮 |
|
||||
|
||||
### 8.3 查看波形
|
||||
|
||||
点击任意测点行,弹出波形详情窗口,显示该测点的完整波形数据。
|
||||
|
||||
### 8.4 删除测点
|
||||
|
||||
点击测点行右侧的「×」按钮,确认后删除该测点及其波形数据。
|
||||
|
||||
### 8.5 导出 CSV
|
||||
|
||||
点击「导出 CSV」按钮,导出当前测线的所有测点数据为 CSV 文件。
|
||||
|
||||
---
|
||||
|
||||
## 九、剖面分析
|
||||
|
||||
切换到底部「剖面」Tab 页。
|
||||
|
||||
### 9.1 测线选择
|
||||
|
||||
与测点页相同,顶部可切换测线查看不同测线的剖面。
|
||||
|
||||
### 9.2 剖面显示
|
||||
|
||||
自动加载所选测线的全部测点波形数据,生成剖面图。
|
||||
|
||||
### 9.3 门窗配置
|
||||
|
||||
点击设置按钮可配置门窗参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| 起始时间 | 门窗起始时间(μs) |
|
||||
| 结束时间 | 门窗结束时间(μs) |
|
||||
| 门窗数量 | 门窗个数 |
|
||||
| 间距模式 | 对数间距 / 线性间距 |
|
||||
|
||||
---
|
||||
|
||||
## 十、工程管理
|
||||
|
||||
切换到底部「工程」Tab 页。
|
||||
|
||||
### 10.1 数据组织结构
|
||||
|
||||
```
|
||||
工程(Project)
|
||||
└── 测线(Session)
|
||||
└── 测点(Frame)
|
||||
├── 元数据(GPS、电流、温度…)
|
||||
└── 波形数据(.bin 文件)
|
||||
```
|
||||
|
||||
### 10.2 新建工程
|
||||
|
||||
1. 点击右上角「+ 新建」
|
||||
2. 输入工程名称
|
||||
3. 确认后自动创建工程和第一条测线
|
||||
|
||||
### 10.3 打开工程
|
||||
|
||||
点击工程卡片上的「打开」按钮,可选择:
|
||||
|
||||
- **继续最近测线** — 恢复上次采集的测线
|
||||
- **新建测线** — 在该工程下创建新测线
|
||||
|
||||
### 10.4 管理测线
|
||||
|
||||
展开工程卡片可看到该工程下的所有测线:
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 激活 | 切换到该测线(后续采集数据存入此测线) |
|
||||
| 导出 | 导出该测线的 CSV 数据 |
|
||||
| 删除 | 删除该测线及其所有测点数据 |
|
||||
| + 新建测线 | 在该工程下新建一条测线 |
|
||||
|
||||
> 当前活跃测线以蓝色高亮显示,且不可删除(需先切换到其他测线)。
|
||||
|
||||
### 10.5 其他操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| .tem | 导出整个工程为 .tem 文件(包含所有测线和波形数据) |
|
||||
| 改名 | 重命名工程 |
|
||||
| 删除 | 删除工程及其所有数据(不可恢复) |
|
||||
|
||||
---
|
||||
|
||||
## 十一、数据导出与分享
|
||||
|
||||
### 11.1 导出方式
|
||||
|
||||
导出时弹出选择:
|
||||
|
||||
- **分享** — 打开系统分享面板,可发送到微信、蓝牙、邮件等
|
||||
- **保存到文件** — 选择手机上的目录保存文件(如下载目录、SD 卡)
|
||||
|
||||
### 11.2 导出格式
|
||||
|
||||
| 格式 | 内容 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| CSV | 测点元数据(GPS、电流、温度等) | Excel 查看、数据分析 |
|
||||
| .tem | 完整工程数据(元数据 + 全部波形) | 数据备份、跨设备传输、导入恢复 |
|
||||
|
||||
### 11.3 CSV 文件内容
|
||||
|
||||
```
|
||||
FrameID, UTC, Longitude, Latitude, Altitude, GPS, SD,
|
||||
AmpRatio, Current, Temp, Battery, Roll, Pitch, Yaw,
|
||||
CH1_peak_uV, CH2_peak_uV, CH3_peak_uV, ...
|
||||
```
|
||||
|
||||
### 11.4 .tem 文件内容
|
||||
|
||||
ZIP 压缩包,包含:
|
||||
|
||||
```
|
||||
project.json ← 工程信息 + 设备配置
|
||||
frames/
|
||||
测线ID_1/
|
||||
000000.bin ← 测点0 完整波形
|
||||
000001.bin ← 测点1 完整波形
|
||||
...
|
||||
测线ID_2/
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、数据导入
|
||||
|
||||
### 12.1 导入 .tem 文件
|
||||
|
||||
1. 在工程页点击「导入 .tem」
|
||||
2. 选择 .tem 文件(从文件管理器、微信接收的文件等)
|
||||
3. 等待导入完成
|
||||
4. 导入后自动创建新工程,保留所有测线和波形数据
|
||||
|
||||
### 12.2 注意事项
|
||||
|
||||
- 导入的工程会创建新的工程 ID,不会覆盖已有工程
|
||||
- 导入过程可能需要几秒到几分钟,取决于数据量
|
||||
- 导入期间请勿关闭 App
|
||||
|
||||
---
|
||||
|
||||
## 十三、主题切换
|
||||
|
||||
App 自动跟随手机系统的深色/浅色模式设置。
|
||||
|
||||
- **Android**:设置 → 显示 → 深色模式
|
||||
- **iOS**:设置 → 显示与亮度 → 外观
|
||||
|
||||
无需在 App 内手动切换。
|
||||
|
||||
---
|
||||
|
||||
## 十四、存储管理
|
||||
|
||||
### 14.1 存储占用
|
||||
|
||||
连续采集时存储占用参考(3 通道 2000 点):
|
||||
|
||||
| 时间 | 16Hz 连续采集 |
|
||||
|------|-------------|
|
||||
| 1 分钟 | ~46 MB |
|
||||
| 10 分钟 | ~460 MB |
|
||||
| 1 小时 | ~2.7 GB |
|
||||
|
||||
### 14.2 空间不足处理
|
||||
|
||||
当手机剩余存储不足 100MB 时,App 会自动停止采集并弹窗提醒。
|
||||
|
||||
### 14.3 释放空间
|
||||
|
||||
1. 先导出需要保留的工程数据(.tem 文件)
|
||||
2. 分享或保存到外部存储
|
||||
3. 在工程页删除已备份的工程
|
||||
4. 工程删除后,关联的所有测线、测点和波形文件将被永久删除
|
||||
|
||||
---
|
||||
|
||||
## 十五、常见问题
|
||||
|
||||
### Q: 连接不上设备
|
||||
|
||||
1. 确认手机已连接到设备的 WiFi 热点
|
||||
2. 确认 IP 地址和端口正确(默认 192.168.4.1:4321)
|
||||
3. 确认没有其他设备(如 Qt 上位机)同时连接
|
||||
4. 尝试断开后重新连接
|
||||
|
||||
### Q: 配置下发超时
|
||||
|
||||
1. 检查 TCP 连接是否正常(顶部状态栏)
|
||||
2. 确认不在采集过程中(需先停止采集)
|
||||
3. 断开重连后重试
|
||||
|
||||
### Q: 波形显示为直线
|
||||
|
||||
1. 切换到 LIN(线性)模式查看
|
||||
2. 检查是否只显示了一个通道(其他通道可能在不同 Y 范围)
|
||||
3. 确认增益设置是否正确
|
||||
|
||||
### Q: 连续采集无法停止
|
||||
|
||||
1. 多次点击停止按钮(App 会自动重试 3 次)
|
||||
2. 如仍无法停止,断开 TCP 连接后重新连接
|
||||
|
||||
### Q: 波形全屏后无法操作
|
||||
|
||||
1. 确保使用单指拖拽、双指缩放
|
||||
2. 双击可恢复原始视图
|
||||
3. 点击右上角「✕」退出全屏
|
||||
|
||||
### Q: 采集数据未显示在测点页
|
||||
|
||||
1. 切换到正确的测线(使用顶部测线选择器)
|
||||
2. 等待几秒让数据库加载完成
|
||||
3. 下拉刷新或切换 Tab 后重新进入
|
||||
|
||||
### Q: 导出的文件在微信中显示为 com.tencent.mm...
|
||||
|
||||
这是正常的,系统分享面板中微信的图标可能显示为包名。点击即可正常发送文件。
|
||||
|
||||
### Q: 手机发热严重
|
||||
|
||||
连续采集时数据处理量大,属于正常现象。建议:
|
||||
- 关闭不需要的后台应用
|
||||
- 避免在高温环境下长时间连续采集
|
||||
- Release 版比 Debug 版性能更好,发热更少
|
||||
|
||||
### Q: 数据丢失
|
||||
|
||||
- 采集数据实时存入数据库和 bin 文件,App 闪退不会丢失已保存的数据
|
||||
- 卸载 App 会清除所有数据,请先导出备份
|
||||
- 建议定期导出 .tem 文件到外部存储
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如遇到本手册未覆盖的问题,请联系开发团队。
|
||||
+112
-89
@@ -1,42 +1,43 @@
|
||||
/**
|
||||
* TEMF v1 binary format
|
||||
* 设备原始帧格式 — 与设备 0x85/0x95 数据帧完全一致
|
||||
*
|
||||
* Header (64 bytes):
|
||||
* [0-3] u8[4] MAGIC = "TEMF" (0x54 0x45 0x4D 0x46)
|
||||
* [4] u8 version = 0x01
|
||||
* [5] u8 channelNum
|
||||
* [6-7] u16 LE sampleDepth
|
||||
* [8] u8 sampleFreqCode
|
||||
* [9-10] u16 LE accNum
|
||||
* [11] u8 ampRatio
|
||||
* [12] u8 srcMode
|
||||
* [13-20] f64 LE timestamp (ms, local)
|
||||
* [21-28] f64 LE latitude (degrees WGS-84)
|
||||
* [29-36] f64 LE longitude (degrees WGS-84)
|
||||
* [37-40] u32 LE utc (seconds since Unix epoch)
|
||||
* [41-44] f32 LE altitude (meters)
|
||||
* [45-46] u16 LE batteryVolt (raw ADC)
|
||||
* [47-48] i16 LE temperature (raw ADC)
|
||||
* [49-50] u16 LE current (raw ADC)
|
||||
* [51] u8 gpsStatus
|
||||
* [52] u8 sdStatus
|
||||
* [53-54] i16 LE roll (degrees × 100)
|
||||
* [55-56] i16 LE pitch (degrees × 100)
|
||||
* [57-58] i16 LE yaw (degrees × 10)
|
||||
* [59-63] u8[5] reserved (zero)
|
||||
* 帧头 (10 字节):
|
||||
* [0-3] u8[4] MAGIC = 0x68 0x68 0xFF 0xFF
|
||||
* [4] u8 flag (0xFE=4字节长度, 0xFF=2字节长度)
|
||||
* [5] u8 func (0x85=数据帧, 0x95=组合源数据帧)
|
||||
* [6-9] u32 LE payloadLen (flag=0xFE时) 或 u16 LE + 0x68 0x68 (flag=0xFF时)
|
||||
*
|
||||
* Data (after header):
|
||||
* int32 LE, row-major: channelNum rows × sampleDepth columns
|
||||
* Values are raw ADC counts; calibrated μV = raw / accNum / AMP_GAIN[ampRatio]
|
||||
* 元数据 (54 字节, 从 offset 10 开始):
|
||||
* [10] u8 devId
|
||||
* [11-14] u32 LE utcSecond
|
||||
* [15-22] f64 LE longitude
|
||||
* [23-30] f64 LE latitude
|
||||
* [31-34] f32 LE altitude
|
||||
* [35-38] f32 LE height
|
||||
* [39] u8 sdGps (高4位=gpsStatus, 低4位=sdStatus)
|
||||
* [40] u8 ampRatio
|
||||
* [41-44] f32 LE roll
|
||||
* [45-48] f32 LE pitch
|
||||
* [49-52] f32 LE yaw
|
||||
* [53] u8 channelNum
|
||||
* [54-57] u32 LE current
|
||||
* [58-59] u16 LE temperature
|
||||
* [60-61] u16 LE batteryVolt
|
||||
* [62] u8 sourceMode
|
||||
* [63] u8 reserved
|
||||
*
|
||||
* ADC 数据 (从 offset 64 开始):
|
||||
* int32 LE, 交织排列 (sample-major):
|
||||
* [S0_CH0][S0_CH1]...[S0_CHn][S1_CH0]...
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { AMP_GAIN } from '../protocol/constants';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
|
||||
const MAGIC = [0x54, 0x45, 0x4d, 0x46] as const; // "TEMF"
|
||||
const VERSION = 0x01;
|
||||
export const HEADER_SIZE = 64;
|
||||
const FRAME_HEADER_SIZE = 10;
|
||||
const META_SIZE = 54;
|
||||
const DATA_OFFSET = FRAME_HEADER_SIZE + META_SIZE; // 64
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,92 +84,114 @@ export function base64ToU8(b64: string): Uint8Array {
|
||||
|
||||
// ── Pure encode / decode ────────────────────────────────────────────────────
|
||||
|
||||
/** Encode a MeasurementFrame into TEMF v1 bytes. No IO. */
|
||||
/** Encode a MeasurementFrame into a complete device-compatible frame (header + meta + ADC). */
|
||||
export function encodeBin(frame: MeasurementFrame): Uint8Array {
|
||||
const m = frame.meta;
|
||||
const ch = frame.adcRaw.length;
|
||||
const spc = frame.adcRaw[0]?.length ?? 0;
|
||||
const buf = new ArrayBuffer(HEADER_SIZE + ch * spc * 4);
|
||||
const payloadLen = META_SIZE + ch * spc * 4;
|
||||
const totalLen = FRAME_HEADER_SIZE + payloadLen;
|
||||
const buf = new ArrayBuffer(totalLen);
|
||||
const dv = new DataView(buf);
|
||||
|
||||
MAGIC.forEach((b, i) => dv.setUint8(i, b));
|
||||
dv.setUint8(4, VERSION);
|
||||
dv.setUint8(5, ch);
|
||||
dv.setUint16(6, spc, true);
|
||||
dv.setUint8(8, frame.sampleFreqCode);
|
||||
dv.setUint16(9, frame.accNum, true);
|
||||
dv.setUint8(11, m.ampRatio);
|
||||
dv.setUint8(12, m.sourceMode);
|
||||
dv.setFloat64(13, frame.timestamp, true);
|
||||
dv.setFloat64(21, m.latitude, true);
|
||||
dv.setFloat64(29, m.longitude, true);
|
||||
dv.setUint32(37, m.utc, true);
|
||||
dv.setFloat32(41, m.altitude, true);
|
||||
dv.setUint16(45, m.batteryVolt, true);
|
||||
dv.setInt16(47, m.temperature, true);
|
||||
dv.setUint16(49, m.current, true);
|
||||
dv.setUint8(51, m.gpsStatus);
|
||||
dv.setUint8(52, m.sdStatus);
|
||||
dv.setInt16(53, Math.round(m.roll * 100), true);
|
||||
dv.setInt16(55, Math.round(m.pitch * 100), true);
|
||||
dv.setInt16(57, Math.round(m.yaw * 10), true);
|
||||
// [59-63] reserved — zero by default
|
||||
// 10-byte frame header (device format, 4-byte length mode)
|
||||
dv.setUint8(0, 0x68);
|
||||
dv.setUint8(1, 0x68);
|
||||
dv.setUint8(2, 0xff);
|
||||
dv.setUint8(3, 0xff);
|
||||
dv.setUint8(4, 0xfe); // flag: 4-byte length
|
||||
dv.setUint8(5, 0x85); // func: DATA_ACK
|
||||
dv.setUint32(6, payloadLen, true);
|
||||
|
||||
let off = HEADER_SIZE;
|
||||
for (const c of frame.adcRaw) {
|
||||
for (let i = 0; i < c.length; i++) { dv.setInt32(off, c[i], true); off += 4; }
|
||||
// 54-byte metadata (offset 10-63)
|
||||
let o = FRAME_HEADER_SIZE;
|
||||
dv.setUint8(o, m.devId ?? 0); o += 1;
|
||||
dv.setUint32(o, m.utc, true); o += 4;
|
||||
dv.setFloat64(o, m.longitude, true); o += 8;
|
||||
dv.setFloat64(o, m.latitude, true); o += 8;
|
||||
dv.setFloat32(o, m.altitude, true); o += 4;
|
||||
dv.setFloat32(o, m.height ?? 0, true); o += 4;
|
||||
dv.setUint8(o, ((m.gpsStatus & 0x0f) << 4) | (m.sdStatus & 0x0f)); o += 1;
|
||||
dv.setUint8(o, m.ampRatio); o += 1;
|
||||
dv.setFloat32(o, m.roll, true); o += 4;
|
||||
dv.setFloat32(o, m.pitch, true); o += 4;
|
||||
dv.setFloat32(o, m.yaw, true); o += 4;
|
||||
dv.setUint8(o, m.channelNum); o += 1;
|
||||
dv.setUint32(o, m.current, true); o += 4;
|
||||
dv.setUint16(o, m.temperature, true); o += 2;
|
||||
dv.setUint16(o, m.batteryVolt, true); o += 2;
|
||||
dv.setUint8(o, m.sourceMode); o += 1;
|
||||
dv.setUint8(o, 0); o += 1;
|
||||
|
||||
// ADC data (offset 64+), interleaved (sample-major)
|
||||
let off = DATA_OFFSET;
|
||||
for (let i = 0; i < spc; i++) {
|
||||
for (let c = 0; c < ch; c++) {
|
||||
dv.setInt32(off, frame.adcRaw[c][i], true);
|
||||
off += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** Decode TEMF v1 bytes into a LoadedBin. Returns null if invalid. No IO. */
|
||||
/** Decode a device-compatible frame binary into a LoadedBin. */
|
||||
export function decodeBin(bytes: Uint8Array): LoadedBin | null {
|
||||
if (bytes.length < HEADER_SIZE) return null;
|
||||
if (bytes.length < DATA_OFFSET) return null;
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
if (MAGIC.some((b, i) => dv.getUint8(i) !== b)) return null;
|
||||
if (dv.getUint8(4) !== VERSION) return null;
|
||||
// Validate frame header magic
|
||||
if (dv.getUint8(0) !== 0x68 || dv.getUint8(1) !== 0x68 ||
|
||||
dv.getUint8(2) !== 0xff || dv.getUint8(3) !== 0xff) return null;
|
||||
|
||||
const channelNum = dv.getUint8(5);
|
||||
const sampleDepth = dv.getUint16(6, true);
|
||||
const sampleFreqCode = dv.getUint8(8);
|
||||
const accNum = dv.getUint16(9, true);
|
||||
const ampRatio = dv.getUint8(11);
|
||||
const srcMode = dv.getUint8(12);
|
||||
const timestamp = dv.getFloat64(13, true);
|
||||
const latitude = dv.getFloat64(21, true);
|
||||
const longitude = dv.getFloat64(29, true);
|
||||
const utc = dv.getUint32(37, true);
|
||||
const altitude = dv.getFloat32(41, true);
|
||||
const batteryVolt = dv.getUint16(45, true);
|
||||
const temperature = dv.getInt16(47, true);
|
||||
const current = dv.getUint16(49, true);
|
||||
const gpsStatus = dv.getUint8(51);
|
||||
const sdStatus = dv.getUint8(52);
|
||||
const roll = dv.getInt16(53, true) / 100;
|
||||
const pitch = dv.getInt16(55, true) / 100;
|
||||
const yaw = dv.getInt16(57, true) / 10;
|
||||
// Parse metadata (offset 10-63)
|
||||
let o = FRAME_HEADER_SIZE;
|
||||
const devId = dv.getUint8(o); o += 1;
|
||||
const utc = dv.getUint32(o, true); o += 4;
|
||||
const longitude = dv.getFloat64(o, true); o += 8;
|
||||
const latitude = dv.getFloat64(o, true); o += 8;
|
||||
const altitude = dv.getFloat32(o, true); o += 4;
|
||||
const height = dv.getFloat32(o, true); o += 4;
|
||||
const sdGps = dv.getUint8(o); o += 1;
|
||||
const ampRatio = dv.getUint8(o); o += 1;
|
||||
const roll = dv.getFloat32(o, true); o += 4;
|
||||
const pitch = dv.getFloat32(o, true); o += 4;
|
||||
const yaw = dv.getFloat32(o, true); o += 4;
|
||||
const channelNum = dv.getUint8(o); o += 1;
|
||||
const current = dv.getUint32(o, true); o += 4;
|
||||
const temperature = dv.getUint16(o, true); o += 2;
|
||||
const batteryVolt = dv.getUint16(o, true); o += 2;
|
||||
const srcMode = dv.getUint8(o); o += 1;
|
||||
|
||||
const expected = HEADER_SIZE + channelNum * sampleDepth * 4;
|
||||
if (bytes.length < expected) return null;
|
||||
const gpsStatus = (sdGps >> 4) & 0x0f;
|
||||
const sdStatus = sdGps & 0x0f;
|
||||
|
||||
// Parse ADC data (offset 64+), interleaved
|
||||
const dataLen = bytes.length - DATA_OFFSET;
|
||||
const totalSamples = dataLen / 4;
|
||||
const sampleDepth = channelNum > 0 ? Math.floor(totalSamples / channelNum) : 0;
|
||||
if (sampleDepth === 0) return null;
|
||||
|
||||
const gain = AMP_GAIN[ampRatio] ?? 1;
|
||||
const ADC_UV_SCALE = (5.0 * 1e6) / 0x7fffffff;
|
||||
const scale = ADC_UV_SCALE / gain;
|
||||
const adcUV: Float64Array[] = [];
|
||||
let off = HEADER_SIZE;
|
||||
for (let ch = 0; ch < channelNum; ch++) {
|
||||
const channel = new Float64Array(sampleDepth);
|
||||
|
||||
for (let c = 0; c < channelNum; c++) {
|
||||
adcUV.push(new Float64Array(sampleDepth));
|
||||
}
|
||||
|
||||
let off = DATA_OFFSET;
|
||||
for (let i = 0; i < sampleDepth; i++) {
|
||||
channel[i] = dv.getInt32(off, true) * scale;
|
||||
for (let c = 0; c < channelNum; c++) {
|
||||
adcUV[c][i] = dv.getInt32(off, true) * scale;
|
||||
off += 4;
|
||||
}
|
||||
adcUV.push(channel);
|
||||
}
|
||||
|
||||
return {
|
||||
adcUV, sampleDepth, channelNum, sampleFreqCode,
|
||||
accNum, ampRatio, srcMode, timestamp,
|
||||
adcUV, sampleDepth, channelNum, sampleFreqCode: 0,
|
||||
accNum: 0, ampRatio, srcMode, timestamp: 0,
|
||||
latitude, longitude, utc, altitude,
|
||||
batteryVolt, temperature, current,
|
||||
gpsStatus, sdStatus, roll, pitch, yaw,
|
||||
|
||||
@@ -95,6 +95,9 @@ export function handleIncomingFrame(frame: RawFrame) {
|
||||
let _lastUIUpdateTime = 0;
|
||||
const UI_THROTTLE_MS = 150;
|
||||
let _processing = false;
|
||||
let _lastDiskCheckTime = 0;
|
||||
const DISK_CHECK_INTERVAL_MS = 30000;
|
||||
const MIN_FREE_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
function processMeasurementPayload(
|
||||
payload: Uint8Array,
|
||||
@@ -126,6 +129,24 @@ function processMeasurementPayload(
|
||||
|
||||
void StorageService.persistFrame(measurementFrame, dataStore.sessionId);
|
||||
|
||||
// Periodic disk space check during continuous acquisition
|
||||
const now2 = Date.now();
|
||||
if (now2 - _lastDiskCheckTime > DISK_CHECK_INTERVAL_MS) {
|
||||
_lastDiskCheckTime = now2;
|
||||
void (async () => {
|
||||
try {
|
||||
const FileSystem = await import('expo-file-system/legacy');
|
||||
const free = await FileSystem.getFreeDiskStorageAsync();
|
||||
if (free < MIN_FREE_BYTES) {
|
||||
const { Alert } = require('react-native');
|
||||
Alert.alert('存储空间不足', `剩余空间不足 100MB,已自动停止采集。\n请清理手机存储或导出数据后删除旧数据。`);
|
||||
void deviceStop();
|
||||
devStore.setDeviceStatus('idle');
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
|
||||
const isSingle = devStore.deviceStatus === 'single';
|
||||
const now = Date.now();
|
||||
if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) {
|
||||
|
||||
@@ -522,6 +522,10 @@ export async function importProject(
|
||||
const meta = BinLoader.decodeBin(binBytes);
|
||||
if (!meta) { onProgress?.(++done, totalFrames); continue; }
|
||||
|
||||
// accNum/sampleFreq not in device frame — get from manifest config
|
||||
const cfg = session.device_config ?? manifest.device_config;
|
||||
const accNum = cfg.accNum ?? 1;
|
||||
|
||||
// Write bin file to DATA_DIR
|
||||
const fname = `${newSessionId}_f${String(frameId).padStart(6, '0')}.bin`;
|
||||
const binPath = DATA_DIR + fname;
|
||||
@@ -537,7 +541,7 @@ export async function importProject(
|
||||
[
|
||||
frameId, newSessionId, meta.timestamp,
|
||||
meta.utc, meta.longitude, meta.latitude, meta.altitude,
|
||||
meta.channelNum, meta.accNum, AMP_GAIN[meta.ampRatio] ?? 1,
|
||||
meta.channelNum, accNum, AMP_GAIN[meta.ampRatio] ?? 1,
|
||||
meta.gpsStatus, meta.sdStatus, meta.ampRatio,
|
||||
meta.current, meta.temperature, meta.batteryVolt,
|
||||
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,
|
||||
|
||||
Reference in New Issue
Block a user