Compare commits

..
3 Commits
Author SHA1 Message Date
zhoujie 898eecbf76 📝 docs(gitignore): 添加 demo 目录到忽略列表
- 将 demo 目录加入 .gitignore 以避免意外提交
2026-06-21 17:54:40 +08:00
zhoujie 9b1ce66f4b ♻️ refactor(bin-loader): 重构二进制帧格式以匹配设备协议
- 将 TEMF v1 格式替换为设备 0x85 数据帧格式(10 字节帧头 + 54 字节元数据 + ADC 数据)
- 重写 encodeBin/decodeBin 函数以支持交织排列的 ADC 采样数据
- 更新记录页面使用设备配置中的 sampleFreqCode 替代从二进制文件解析的值
- 在导入项目时从设备配置获取 accNum 以适配新格式
2026-06-21 17:34:58 +08:00
zhoujie e1ca0d8512 ✨ feat(projects): 更新删除工程提示并添加存储空间监控
- 修改删除工程确认提示,明确说明会永久删除所有测线和测点数据
- 在连续采集过程中每30秒检查一次磁盘空间,低于100MB时自动停止采集并弹窗提醒

📝 docs(user-manual): 添加完整用户手册

- 新增 TEM Receiver 用户手册,涵盖安装、连接、配置、采集、波形查看、测点管理、剖面分析、工程管理、数据导出导入、主题切换、存储管理及常见问题等全部功能说明
2026-06-21 16:58:20 +08:00
7 changed files with 630 additions and 93 deletions
+2
View File
@@ -48,3 +48,5 @@ __pycache__/
*.apk *.apk
*.aab *.aab
*.ipa *.ipa
demo/
+1 -1
View File
@@ -275,7 +275,7 @@ export default function ProjectsScreen() {
const handleDeleteProject = (project: ProjectInfo) => { const handleDeleteProject = (project: ProjectInfo) => {
Alert.alert( Alert.alert(
'删除工程', '删除工程',
`确定删除工程「${project.name}」?\n测线数据将保留,但与工程的关联会断开。`, `确定删除工程「${project.name}」?\n该工程下的所有测线和测点数据将被永久删除,不可恢复。`,
[ [
{ text: '取消', style: 'cancel' }, { text: '取消', style: 'cancel' },
{ {
+3 -1
View File
@@ -192,6 +192,8 @@ export default function RecordsScreen() {
await resumeSession(newSessionId, projectId); await resumeSession(newSessionId, projectId);
}; };
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
const handleFramePress = async (pf: PersistedFrame) => { const handleFramePress = async (pf: PersistedFrame) => {
if (!pf.binPath) return; if (!pf.binPath) return;
const loaded = await BinLoader.loadBinFile(pf.binPath); const loaded = await BinLoader.loadBinFile(pf.binPath);
@@ -202,7 +204,7 @@ export default function RecordsScreen() {
adcUV: loaded.adcUV, adcUV: loaded.adcUV,
accNum: pf.accNum, accNum: pf.accNum,
gain: pf.gain, gain: pf.gain,
sampleFreqCode: loaded.sampleFreqCode, sampleFreqCode,
timestamp: pf.timestamp, timestamp: pf.timestamp,
frameId: pf.frameId, frameId: pf.frameId,
}; };
+485
View File
@@ -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 文件到外部存储
---
## 技术支持
如遇到本手册未覆盖的问题,请联系开发团队。
+113 -90
View File
@@ -1,42 +1,43 @@
/** /**
* TEMF v1 binary format * 设备原始帧格式 — 与设备 0x85/0x95 数据帧完全一致
* *
* Header (64 bytes): * 帧头 (10 字节):
* [0-3] u8[4] MAGIC = "TEMF" (0x54 0x45 0x4D 0x46) * [0-3] u8[4] MAGIC = 0x68 0x68 0xFF 0xFF
* [4] u8 version = 0x01 * [4] u8 flag (0xFE=4字节长度, 0xFF=2字节长度)
* [5] u8 channelNum * [5] u8 func (0x85=数据帧, 0x95=组合源数据帧)
* [6-7] u16 LE sampleDepth * [6-9] u32 LE payloadLen (flag=0xFE时) 或 u16 LE + 0x68 0x68 (flag=0xFF时)
* [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)
* *
* Data (after header): * 元数据 (54 字节, 从 offset 10 开始):
* int32 LE, row-major: channelNum rows × sampleDepth columns * [10] u8 devId
* Values are raw ADC counts; calibrated μV = raw / accNum / AMP_GAIN[ampRatio] * [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 * as FileSystem from 'expo-file-system/legacy';
import { AMP_GAIN } from '../protocol/constants'; import { AMP_GAIN } from '../protocol/constants';
import type { MeasurementFrame } from '../protocol/types'; import type { MeasurementFrame } from '../protocol/types';
const MAGIC = [0x54, 0x45, 0x4d, 0x46] as const; // "TEMF" const FRAME_HEADER_SIZE = 10;
const VERSION = 0x01; const META_SIZE = 54;
export const HEADER_SIZE = 64; const DATA_OFFSET = FRAME_HEADER_SIZE + META_SIZE; // 64
// ── Public types ──────────────────────────────────────────────────────────── // ── Public types ────────────────────────────────────────────────────────────
@@ -83,92 +84,114 @@ export function base64ToU8(b64: string): Uint8Array {
// ── Pure encode / decode ──────────────────────────────────────────────────── // ── 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 { export function encodeBin(frame: MeasurementFrame): Uint8Array {
const m = frame.meta; const m = frame.meta;
const ch = frame.adcRaw.length; const ch = frame.adcRaw.length;
const spc = frame.adcRaw[0]?.length ?? 0; 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); const dv = new DataView(buf);
MAGIC.forEach((b, i) => dv.setUint8(i, b)); // 10-byte frame header (device format, 4-byte length mode)
dv.setUint8(4, VERSION); dv.setUint8(0, 0x68);
dv.setUint8(5, ch); dv.setUint8(1, 0x68);
dv.setUint16(6, spc, true); dv.setUint8(2, 0xff);
dv.setUint8(8, frame.sampleFreqCode); dv.setUint8(3, 0xff);
dv.setUint16(9, frame.accNum, true); dv.setUint8(4, 0xfe); // flag: 4-byte length
dv.setUint8(11, m.ampRatio); dv.setUint8(5, 0x85); // func: DATA_ACK
dv.setUint8(12, m.sourceMode); dv.setUint32(6, payloadLen, true);
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
let off = HEADER_SIZE; // 54-byte metadata (offset 10-63)
for (const c of frame.adcRaw) { let o = FRAME_HEADER_SIZE;
for (let i = 0; i < c.length; i++) { dv.setInt32(off, c[i], true); off += 4; } 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); 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 { 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); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (MAGIC.some((b, i) => dv.getUint8(i) !== b)) return null; // Validate frame header magic
if (dv.getUint8(4) !== VERSION) return null; if (dv.getUint8(0) !== 0x68 || dv.getUint8(1) !== 0x68 ||
dv.getUint8(2) !== 0xff || dv.getUint8(3) !== 0xff) return null;
const channelNum = dv.getUint8(5); // Parse metadata (offset 10-63)
const sampleDepth = dv.getUint16(6, true); let o = FRAME_HEADER_SIZE;
const sampleFreqCode = dv.getUint8(8); const devId = dv.getUint8(o); o += 1;
const accNum = dv.getUint16(9, true); const utc = dv.getUint32(o, true); o += 4;
const ampRatio = dv.getUint8(11); const longitude = dv.getFloat64(o, true); o += 8;
const srcMode = dv.getUint8(12); const latitude = dv.getFloat64(o, true); o += 8;
const timestamp = dv.getFloat64(13, true); const altitude = dv.getFloat32(o, true); o += 4;
const latitude = dv.getFloat64(21, true); const height = dv.getFloat32(o, true); o += 4;
const longitude = dv.getFloat64(29, true); const sdGps = dv.getUint8(o); o += 1;
const utc = dv.getUint32(37, true); const ampRatio = dv.getUint8(o); o += 1;
const altitude = dv.getFloat32(41, true); const roll = dv.getFloat32(o, true); o += 4;
const batteryVolt = dv.getUint16(45, true); const pitch = dv.getFloat32(o, true); o += 4;
const temperature = dv.getInt16(47, true); const yaw = dv.getFloat32(o, true); o += 4;
const current = dv.getUint16(49, true); const channelNum = dv.getUint8(o); o += 1;
const gpsStatus = dv.getUint8(51); const current = dv.getUint32(o, true); o += 4;
const sdStatus = dv.getUint8(52); const temperature = dv.getUint16(o, true); o += 2;
const roll = dv.getInt16(53, true) / 100; const batteryVolt = dv.getUint16(o, true); o += 2;
const pitch = dv.getInt16(55, true) / 100; const srcMode = dv.getUint8(o); o += 1;
const yaw = dv.getInt16(57, true) / 10;
const expected = HEADER_SIZE + channelNum * sampleDepth * 4; const gpsStatus = (sdGps >> 4) & 0x0f;
if (bytes.length < expected) return null; 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 gain = AMP_GAIN[ampRatio] ?? 1;
const ADC_UV_SCALE = (5.0 * 1e6) / 0x7fffffff; const ADC_UV_SCALE = (5.0 * 1e6) / 0x7fffffff;
const scale = ADC_UV_SCALE / gain; const scale = ADC_UV_SCALE / gain;
const adcUV: Float64Array[] = []; const adcUV: Float64Array[] = [];
let off = HEADER_SIZE;
for (let ch = 0; ch < channelNum; ch++) { for (let c = 0; c < channelNum; c++) {
const channel = new Float64Array(sampleDepth); adcUV.push(new Float64Array(sampleDepth));
for (let i = 0; i < sampleDepth; i++) { }
channel[i] = dv.getInt32(off, true) * scale;
let off = DATA_OFFSET;
for (let i = 0; i < sampleDepth; i++) {
for (let c = 0; c < channelNum; c++) {
adcUV[c][i] = dv.getInt32(off, true) * scale;
off += 4; off += 4;
} }
adcUV.push(channel);
} }
return { return {
adcUV, sampleDepth, channelNum, sampleFreqCode, adcUV, sampleDepth, channelNum, sampleFreqCode: 0,
accNum, ampRatio, srcMode, timestamp, accNum: 0, ampRatio, srcMode, timestamp: 0,
latitude, longitude, utc, altitude, latitude, longitude, utc, altitude,
batteryVolt, temperature, current, batteryVolt, temperature, current,
gpsStatus, sdStatus, roll, pitch, yaw, gpsStatus, sdStatus, roll, pitch, yaw,
+21
View File
@@ -95,6 +95,9 @@ export function handleIncomingFrame(frame: RawFrame) {
let _lastUIUpdateTime = 0; let _lastUIUpdateTime = 0;
const UI_THROTTLE_MS = 150; const UI_THROTTLE_MS = 150;
let _processing = false; let _processing = false;
let _lastDiskCheckTime = 0;
const DISK_CHECK_INTERVAL_MS = 30000;
const MIN_FREE_BYTES = 100 * 1024 * 1024; // 100MB
function processMeasurementPayload( function processMeasurementPayload(
payload: Uint8Array, payload: Uint8Array,
@@ -126,6 +129,24 @@ function processMeasurementPayload(
void StorageService.persistFrame(measurementFrame, dataStore.sessionId); 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 isSingle = devStore.deviceStatus === 'single';
const now = Date.now(); const now = Date.now();
if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) { if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) {
+5 -1
View File
@@ -522,6 +522,10 @@ export async function importProject(
const meta = BinLoader.decodeBin(binBytes); const meta = BinLoader.decodeBin(binBytes);
if (!meta) { onProgress?.(++done, totalFrames); continue; } 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 // Write bin file to DATA_DIR
const fname = `${newSessionId}_f${String(frameId).padStart(6, '0')}.bin`; const fname = `${newSessionId}_f${String(frameId).padStart(6, '0')}.bin`;
const binPath = DATA_DIR + fname; const binPath = DATA_DIR + fname;
@@ -537,7 +541,7 @@ export async function importProject(
[ [
frameId, newSessionId, meta.timestamp, frameId, newSessionId, meta.timestamp,
meta.utc, meta.longitude, meta.latitude, meta.altitude, 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.gpsStatus, meta.sdStatus, meta.ampRatio,
meta.current, meta.temperature, meta.batteryVolt, meta.current, meta.temperature, meta.batteryVolt,
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath, meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,