♻️ refactor(bin-loader): 重构二进制帧格式以匹配设备协议

- 将 TEMF v1 格式替换为设备 0x85 数据帧格式(10 字节帧头 + 54 字节元数据 + ADC 数据)
- 重写 encodeBin/decodeBin 函数以支持交织排列的 ADC 采样数据
- 更新记录页面使用设备配置中的 sampleFreqCode 替代从二进制文件解析的值
- 在导入项目时从设备配置获取 accNum 以适配新格式
This commit is contained in:
zhoujie 2026-06-21 17:34:58 +08:00
parent e1ca0d8512
commit 9b1ce66f4b
3 changed files with 121 additions and 92 deletions

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,
}; };

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,

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,