import * as SQLite from 'expo-sqlite'; import * as FileSystem from 'expo-file-system/legacy'; import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types'; 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 { 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); `); } return _db; } 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 { await getDb(); await ensureDataDir(); } // ── Project CRUD ──────────────────────────────────────────────────────────── function generateProjectId(): string { const now = new Date(); const pad = (n: number, d = 2) => String(n).padStart(d, '0'); return ( `P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` ); } export async function createProject(name: string): Promise { const d = await getDb(); const id = generateProjectId(); await d.runAsync( 'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)', [id, name.trim(), Date.now()], ); return id; } export async function listProjects(): Promise { 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 { const d = await getDb(); await d.runAsync('UPDATE projects SET name = ? WHERE project_id = ?', [name.trim(), projectId]); } export async function deleteProject(projectId: string): Promise { const d = await getDb(); await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]); } // ── Session CRUD ──────────────────────────────────────────────────────────── export async function ensureSession(sessionId: string, projectId?: string | null): Promise { 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 { 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 { 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 { const d = await getDb(); await d.runAsync('UPDATE sessions SET project_id = ? WHERE session_id = ?', [projectId, sessionId]); } export async function deleteSession(sessionId: string): Promise { 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 { 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 REPLACE 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 { const d = await getDb(); const rows = await d.getAllAsync( '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 { 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 { 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 { 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 { 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 = {}; 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 outPath = DATA_DIR + `TEM_${proj.name}_${Date.now()}.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 { 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; } // 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, meta.accNum, meta.ampRatio, 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, }; }