TriloopTem_App/src/services/StorageService.ts
zhoujie 9b1ce66f4b ♻️ refactor(bin-loader): 重构二进制帧格式以匹配设备协议
- 将 TEMF v1 格式替换为设备 0x85 数据帧格式(10 字节帧头 + 54 字节元数据 + ADC 数据)
- 重写 encodeBin/decodeBin 函数以支持交织排列的 ADC 采样数据
- 更新记录页面使用设备配置中的 sampleFreqCode 替代从二进制文件解析的值
- 在导入项目时从设备配置获取 accNum 以适配新格式
2026-06-21 17:34:58 +08:00

566 lines
20 KiB
TypeScript

import * as SQLite from 'expo-sqlite';
import * as FileSystem from 'expo-file-system/legacy';
import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types';
import { AMP_GAIN } from '../protocol/constants';
import * as BinLoader from './BinLoader';
import * as TemBundle from './TemBundle';
const DATA_DIR = (FileSystem.documentDirectory ?? '') + 'tem_data/';
// ── Public types ────────────────────────────────────────────────────────────
export interface ProjectInfo {
projectId: string;
name: string;
createdAt: number;
lineCount: number;
}
export interface SessionInfo {
sessionId: string;
createdAt: number;
frameCount: number;
projectId: string | null;
}
export interface PersistedFrame {
frameId: number;
sessionId: string;
timestamp: number;
accNum: number;
gain: number;
binPath: string | null;
meta: MeasurementMeta;
}
// ── DB init ─────────────────────────────────────────────────────────────────
let _db: SQLite.SQLiteDatabase | null = null;
async function getDb(): Promise<SQLite.SQLiteDatabase> {
if (!_db) {
_db = await SQLite.openDatabaseAsync('tem.db');
await _db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
frame_count INTEGER NOT NULL DEFAULT 0,
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS frames (
frame_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
ts INTEGER,
utc INTEGER, lon REAL, lat REAL, alt REAL,
ch_num INTEGER, acc_num INTEGER, gain REAL,
gps_st INTEGER, sd_st INTEGER, amp_ratio INTEGER,
current INTEGER, temp INTEGER, batt INTEGER,
roll REAL, pitch REAL, yaw REAL, src_mode INTEGER,
bin_path TEXT,
PRIMARY KEY (frame_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
`);
await runMigrations(_db);
}
return _db;
}
async function runMigrations(db: SQLite.SQLiteDatabase) {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
let version = row?.user_version ?? 0;
if (version < 1) {
try { await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT'); } catch {}
await db.runAsync('PRAGMA user_version = 1');
version = 1;
}
if (version < 2) {
const orphans = await db.getAllAsync<{ session_id: string }>(
'SELECT session_id FROM sessions WHERE project_id IS NULL',
);
if (orphans.length > 0) {
const defId = generateProjectId();
await db.runAsync(
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
[defId, '默认工程', Date.now()],
);
await db.runAsync('UPDATE sessions SET project_id = ? WHERE project_id IS NULL', [defId]);
}
await db.runAsync('PRAGMA user_version = 2');
version = 2;
}
}
async function ensureDataDir() {
const info = await FileSystem.getInfoAsync(DATA_DIR);
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
}
export async function initStorage(): Promise<void> {
await getDb();
await ensureDataDir();
}
export async function hasAnyProject(): Promise<boolean> {
const d = await getDb();
const row = await d.getFirstAsync<{ cnt: number }>('SELECT COUNT(*) AS cnt FROM projects');
return (row?.cnt ?? 0) > 0;
}
export async function getFirstProject(): Promise<ProjectInfo | null> {
const d = await getDb();
const r = await d.getFirstAsync<{ project_id: string; name: string; created_at: number }>(
'SELECT project_id, name, created_at FROM projects ORDER BY created_at DESC LIMIT 1',
);
if (!r) return null;
return { projectId: r.project_id, name: r.name, createdAt: r.created_at, lineCount: 0 };
}
// ── Project CRUD ────────────────────────────────────────────────────────────
function generateProjectId(): string {
const now = new Date();
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
const rand = Math.random().toString(36).slice(2, 6);
return (
`P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` +
`${pad(now.getMilliseconds(), 3)}_${rand}`
);
}
function sanitizeName(name: string): string {
return name.trim().replace(/[\/\\:*?"<>|.\x00]/g, '_').slice(0, 50);
}
export async function createProject(name: string): Promise<string> {
const d = await getDb();
const id = generateProjectId();
await d.runAsync(
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
[id, sanitizeName(name), Date.now()],
);
return id;
}
export async function listProjects(): Promise<ProjectInfo[]> {
const d = await getDb();
const rows = await d.getAllAsync<{
project_id: string; name: string; created_at: number; line_count: number;
}>(
`SELECT p.project_id, p.name, p.created_at,
COUNT(s.session_id) AS line_count
FROM projects p
LEFT JOIN sessions s ON s.project_id = p.project_id
GROUP BY p.project_id
ORDER BY p.created_at DESC`,
);
return rows.map(r => ({
projectId: r.project_id,
name: r.name,
createdAt: r.created_at,
lineCount: r.line_count,
}));
}
export async function renameProject(projectId: string, name: string): Promise<void> {
const d = await getDb();
await d.runAsync('UPDATE projects SET name = ? WHERE project_id = ?', [name.trim(), projectId]);
}
export async function deleteProject(projectId: string): Promise<void> {
const d = await getDb();
const sessions = await d.getAllAsync<{ session_id: string }>(
'SELECT session_id FROM sessions WHERE project_id = ?', [projectId],
);
for (const s of sessions) {
const frames = await d.getAllAsync<{ bin_path: string | null }>(
'SELECT bin_path FROM frames WHERE session_id = ?', [s.session_id],
);
for (const f of frames) {
if (f.bin_path) { try { await FileSystem.deleteAsync(f.bin_path, { idempotent: true }); } catch {} }
}
await d.runAsync('DELETE FROM frames WHERE session_id = ?', [s.session_id]);
}
await d.runAsync('DELETE FROM sessions WHERE project_id = ?', [projectId]);
await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]);
}
// ── Session CRUD ────────────────────────────────────────────────────────────
export async function ensureSession(sessionId: string, projectId?: string | null): Promise<void> {
const d = await getDb();
await d.runAsync(
'INSERT OR IGNORE INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
[sessionId, Date.now(), projectId ?? null],
);
if (projectId) {
await d.runAsync(
'UPDATE sessions SET project_id = ? WHERE session_id = ? AND project_id IS NULL',
[projectId, sessionId],
);
}
}
export async function listSessions(): Promise<SessionInfo[]> {
const d = await getDb();
const rows = await d.getAllAsync<{
session_id: string; created_at: number; frame_count: number; project_id: string | null;
}>(
'SELECT session_id, created_at, frame_count, project_id FROM sessions ORDER BY created_at DESC',
);
return rows.map(r => ({
sessionId: r.session_id,
createdAt: r.created_at,
frameCount: r.frame_count,
projectId: r.project_id,
}));
}
export async function listSessionsByProject(projectId: string): Promise<SessionInfo[]> {
const d = await getDb();
const rows = await d.getAllAsync<{
session_id: string; created_at: number; frame_count: number;
}>(
'SELECT session_id, created_at, frame_count FROM sessions WHERE project_id = ? ORDER BY created_at DESC',
[projectId],
);
return rows.map(r => ({
sessionId: r.session_id,
createdAt: r.created_at,
frameCount: r.frame_count,
projectId,
}));
}
export async function assignSessionToProject(sessionId: string, projectId: string | null): Promise<void> {
const d = await getDb();
await d.runAsync('UPDATE sessions SET project_id = ? WHERE session_id = ?', [projectId, sessionId]);
}
export async function deleteSession(sessionId: string): Promise<void> {
const d = await getDb();
try {
const files = await FileSystem.readDirectoryAsync(DATA_DIR);
await Promise.all(
files
.filter(f => f.startsWith(sessionId))
.map(f => FileSystem.deleteAsync(DATA_DIR + f, { idempotent: true })),
);
} catch { /* directory may not exist */ }
await d.runAsync('DELETE FROM frames WHERE session_id = ?', [sessionId]);
await d.runAsync('DELETE FROM sessions WHERE session_id = ?', [sessionId]);
}
// ── Frame persistence ────────────────────────────────────────────────────────
export async function persistFrame(frame: MeasurementFrame, sessionId: string): Promise<void> {
const d = await getDb();
const m = frame.meta;
let binPath: string | null = null;
try {
const fname = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
binPath = DATA_DIR + fname;
await BinLoader.saveBinFile(binPath, frame);
} catch { binPath = null; }
await d.runAsync(
`INSERT OR IGNORE INTO frames (
frame_id, session_id, ts, utc, lon, lat, alt,
ch_num, acc_num, gain, gps_st, sd_st, amp_ratio,
current, temp, batt, roll, pitch, yaw, src_mode, bin_path
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[
frame.frameId, sessionId, frame.timestamp,
m.utc, m.longitude, m.latitude, m.altitude,
m.channelNum, frame.accNum, frame.gain,
m.gpsStatus, m.sdStatus, m.ampRatio,
m.current, m.temperature, m.batteryVolt,
m.roll, m.pitch, m.yaw, m.sourceMode, binPath,
],
);
await d.runAsync(
'UPDATE sessions SET frame_count = frame_count + 1 WHERE session_id = ?',
[sessionId],
);
}
export async function loadSessionFrames(sessionId: string): Promise<PersistedFrame[]> {
const d = await getDb();
const rows = await d.getAllAsync<any>(
'SELECT * FROM frames WHERE session_id = ? ORDER BY frame_id DESC',
[sessionId],
);
return rows.map(r => ({
frameId: r.frame_id,
sessionId: r.session_id,
timestamp: r.ts,
accNum: r.acc_num,
gain: r.gain,
binPath: r.bin_path,
meta: {
devId: 0, utc: r.utc,
longitude: r.lon, latitude: r.lat, altitude: r.alt, height: 0,
sdStatus: r.sd_st, gpsStatus: r.gps_st, ampRatio: r.amp_ratio,
roll: r.roll, pitch: r.pitch, yaw: r.yaw,
channelNum: r.ch_num, current: r.current,
temperature: r.temp, batteryVolt: r.batt, sourceMode: r.src_mode,
} as MeasurementMeta,
}));
}
export async function deleteFrame(frameId: number, sessionId: string): Promise<void> {
const d = await getDb();
const row = await d.getFirstAsync<{ bin_path: string | null }>(
'SELECT bin_path FROM frames WHERE frame_id = ? AND session_id = ?',
[frameId, sessionId],
);
if (row?.bin_path) {
try { await FileSystem.deleteAsync(row.bin_path, { idempotent: true }); } catch { /* ignore */ }
}
await d.runAsync('DELETE FROM frames WHERE frame_id = ? AND session_id = ?', [frameId, sessionId]);
await d.runAsync('UPDATE sessions SET frame_count = MAX(0, frame_count - 1) WHERE session_id = ?', [sessionId]);
}
export async function getMaxFrameId(sessionId: string): Promise<number> {
const d = await getDb();
const row = await d.getFirstAsync<{ max_id: number | null }>(
'SELECT MAX(frame_id) AS max_id FROM frames WHERE session_id = ?',
[sessionId],
);
return row?.max_id ?? -1;
}
// ── CSV export ──────────────────────────────────────────────────────────────
export async function exportSessionMetaCsv(sessionId: string): Promise<string> {
const frames = await loadSessionFrames(sessionId);
if (frames.length === 0) throw new Error('会话无数据');
const { formatUtc } = await import('../utils/format');
const maxCh = frames.reduce((m, f) => Math.max(m, f.meta.channelNum), 0);
const chHeaders = Array.from({ length: maxCh }, (_, i) => `CH${i + 1}_peak_uV`).join(',');
const header = `FrameID,UTC,Longitude,Latitude,Altitude,GPS,SD,AmpRatio,Current,Temp,Battery,Roll,Pitch,Yaw,${chHeaders}`;
const rows: string[] = [];
for (const f of frames) {
const m = f.meta;
let peaks: string[] = Array(maxCh).fill('');
if (f.binPath) {
const bin = await BinLoader.loadBinFile(f.binPath);
if (bin) {
peaks = Array.from({ length: maxCh }, (_, i) => {
const ch = bin.adcUV[i];
if (!ch) return '';
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
return pk.toFixed(4);
});
}
}
rows.push([
f.frameId, formatUtc(m.utc),
m.longitude.toFixed(7), m.latitude.toFixed(7), m.altitude.toFixed(2),
m.gpsStatus, m.sdStatus, m.ampRatio, m.current, m.temperature, m.batteryVolt,
m.roll.toFixed(2), m.pitch.toFixed(2), m.yaw.toFixed(2),
...peaks,
].join(','));
}
const csv = [header, ...rows].join('\n');
await ensureDataDir();
const path = DATA_DIR + `TEM_${sessionId}.csv`;
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
return path;
}
// ── .tem export ─────────────────────────────────────────────────────────────
export async function exportProject(
projectId: string,
deviceConfig: SetupConfig,
gateConfig: GateConfig,
onProgress?: (current: number, total: number) => void,
): Promise<string> {
const d = await getDb();
const proj = await d.getFirstAsync<{ name: string; created_at: number }>(
'SELECT name, created_at FROM projects WHERE project_id = ?',
[projectId],
);
if (!proj) throw new Error('工程不存在');
const sessions = await listSessionsByProject(projectId);
// Collect all frames + bin bytes
const bins: Record<string, Uint8Array> = {};
const sessionManifests: TemBundle.SessionManifest[] = [];
let totalFrames = sessions.reduce((s, x) => s + x.frameCount, 0);
let done = 0;
for (const session of sessions) {
const frames = await loadSessionFrames(session.sessionId);
for (const f of frames) {
if (f.binPath) {
const bytes = await BinLoader.readFileBytes(f.binPath);
if (bytes) {
const relPath = TemBundle.bundleBinPath(session.sessionId, f.frameId);
bins[relPath] = bytes;
}
}
onProgress?.(++done, totalFrames);
}
sessionManifests.push({
id: session.sessionId,
created_at: session.createdAt,
frame_count: session.frameCount,
device_config: deviceConfig,
});
}
const manifest: TemBundle.ProjectManifest = {
schema_version: '1.0',
exported_at: new Date().toISOString(),
project: { id: projectId, name: proj.name, created_at: proj.created_at },
device_config: deviceConfig,
gate_config: gateConfig,
sessions: sessionManifests,
};
const bundleBytes = await TemBundle.packBundle(manifest, bins);
await ensureDataDir();
const safeName = sanitizeName(proj.name);
const ts = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const outPath = DATA_DIR + `TEM_${safeName}_${ts}.tem`;
await BinLoader.writeFileBytes(outPath, bundleBytes);
return outPath;
}
// ── .tem import ─────────────────────────────────────────────────────────────
export interface ImportResult {
projectId: string;
projectName: string;
sessionCount: number;
frameCount: number;
}
export async function importProject(
bundleUri: string,
onProgress?: (current: number, total: number) => void,
): Promise<ImportResult> {
await ensureDataDir();
const d = await getDb();
// Copy to temp path (handles content:// URIs on Android)
const tmpPath = DATA_DIR + `tmp_import_${Date.now()}.tem`;
try {
await FileSystem.copyAsync({ from: bundleUri, to: tmpPath });
} catch {
// URI may already be a file:// path — try reading directly
}
let bundleBytes: Uint8Array | null = null;
try {
bundleBytes = await BinLoader.readFileBytes(tmpPath);
} catch { /* fall through */ }
if (!bundleBytes) {
bundleBytes = await BinLoader.readFileBytes(bundleUri);
}
if (!bundleBytes) throw new Error('无法读取 .tem 文件');
// Clean up temp file (don't await — non-critical)
FileSystem.deleteAsync(tmpPath, { idempotent: true }).catch(() => {});
const { manifest, bins } = await TemBundle.parseBundle(bundleBytes);
// Allocate new project ID to avoid collision
const newProjectId = generateProjectId();
await d.runAsync(
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
[newProjectId, manifest.project.name, manifest.project.created_at],
);
const totalFrames = Object.keys(bins).length;
let done = 0;
let importedFrames = 0;
for (const session of manifest.sessions) {
// Use original session ID if not already in DB; otherwise suffix with timestamp
const existing = await d.getFirstAsync<{ session_id: string }>(
'SELECT session_id FROM sessions WHERE session_id = ?',
[session.id],
);
const newSessionId = existing ? `${session.id}_${Date.now()}` : session.id;
await d.runAsync(
'INSERT INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
[newSessionId, session.created_at, newProjectId],
);
let sessionFrameCount = 0;
for (const [relPath, binBytes] of Object.entries(bins)) {
const parsed = TemBundle.parseBinPath(relPath);
if (!parsed || parsed.sessionId !== session.id) continue;
const { frameId } = parsed;
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;
await BinLoader.writeFileBytes(binPath, binBytes);
// Insert frame record
await d.runAsync(
`INSERT OR IGNORE INTO frames (
frame_id, session_id, ts, utc, lon, lat, alt,
ch_num, acc_num, gain, gps_st, sd_st, amp_ratio,
current, temp, batt, roll, pitch, yaw, src_mode, bin_path
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[
frameId, newSessionId, meta.timestamp,
meta.utc, meta.longitude, meta.latitude, meta.altitude,
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,
],
);
sessionFrameCount++;
importedFrames++;
onProgress?.(++done, totalFrames);
}
await d.runAsync('UPDATE sessions SET frame_count = ? WHERE session_id = ?', [sessionFrameCount, newSessionId]);
}
return {
projectId: newProjectId,
projectName: manifest.project.name,
sessionCount: manifest.sessions.length,
frameCount: importedFrames,
};
}