diff --git a/.gitignore b/.gitignore
index d914c32..84d5514 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,7 @@ yarn-error.*
# generated native folders
/ios
/android
+
+# python
+*.pyc
+__pycache__/
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 30bcd2e..7ebc182 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,63 +1,73 @@
import { Tabs } from 'expo-router';
-import { Platform } from 'react-native';
+import { Platform, View } from 'react-native';
import { SymbolView } from 'expo-symbols';
+import { Text } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { GlobalStatusBar } from '../../src/components/device/GlobalStatusBar';
+import { Colors } from '../../src/design/tokens';
-const ACTIVE = '#4a9eff';
+const ACTIVE = Colors.blue.fg;
const INACTIVE = '#555';
-const BG = '#111111';
+const TAB_BG = '#111111';
-// SymbolView tintColor is typed as string but Tabs passes ColorValue — cast to avoid TS error
-function TabIcon({ name, color }: { name: string; color: any }) {
- return ;
+function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
+ if (Platform.OS === 'ios') {
+ return ;
+ }
+ return {emoji};
}
export default function TabLayout() {
+ const insets = useSafeAreaInsets();
+
return (
-
- (
-
- ),
+
+
+
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
-
+ >
+ {/* Hidden screens */}
+
+
+
+
+
+ {/* Visible tabs */}
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+
);
}
diff --git a/app/(tabs)/control.tsx b/app/(tabs)/control.tsx
index a782500..6b1a773 100644
--- a/app/(tabs)/control.tsx
+++ b/app/(tabs)/control.tsx
@@ -1,16 +1,27 @@
import React, { useState } from 'react';
-import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
+import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, Platform } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { ParamForm } from '../../src/components/ParamForm';
import { useDevice } from '../../src/hooks/useDevice';
-import { useDeviceStore } from '../../src/stores/deviceStore';
import { useDataStore } from '../../src/stores/dataStore';
export default function ControlScreen() {
+ const insets = useSafeAreaInsets();
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
const frame = useDataStore((s) => s.currentFrame);
+ const sessionId = useDataStore((s) => s.sessionId);
+ const projectId = useDataStore((s) => s.projectId);
const [configDirty, setConfigDirty] = useState(false);
+ const [projectName, setProjectName] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!projectId) { setProjectName(null); return; }
+ import('../../src/services/StorageService').then(({ listProjects }) =>
+ listProjects().then((ps) => setProjectName(ps.find(p => p.projectId === projectId)?.name ?? null)),
+ );
+ }, [projectId]);
const handleSetup = async () => {
await setup();
@@ -19,132 +30,190 @@ export default function ControlScreen() {
if (!connected) {
return (
-
- 未连接设备
- router.replace('/connect')}>
- 前往连接
+
+ ⊘
+ 未连接设备
+ router.push('/connect')} activeOpacity={0.8}>
+ 前往连接
);
}
const running = deviceStatus === 'running';
- const canStart = !running && !busy;
- const canStop = (running || deviceStatus === 'single') && !busy;
+ const single = deviceStatus === 'single';
+ const canStart = !running && !single && !busy;
+ const canStop = (running || single) && !busy;
return (
-
+
+ {/* Project / line context */}
+
+ {projectName
+ ? {projectName} · {sessionId}
+ : {sessionId}
+ }
+
{/* Control buttons */}
-
+
- {busy ? : 连续采集}
+ {busy && !canStop
+ ?
+ : <>
+ ▶
+ 连续采集
+ >
+ }
- 单次
+ ◎
+ 单 次
- 停止
+ {busy && canStop
+ ?
+ : <>
+ ■
+ 停 止
+ >
+ }
- {/* Last frame info */}
+ {/* Frame info */}
{frame && (
-
-
- 帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
-
-
- 电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
-
+
+
+ 帧号
+ #{frame.frameId}
+
+
+
+ 叠加
+ {frame.accNum}次
+
+
+
+ 通道
+ {frame.meta?.channelNum ?? '-'}ch
+
+
+
+ 电流
+ {frame.meta?.current ?? '--'}
+
)}
{/* Param form */}
-
-
- 采集参数
- {configDirty && 未下发}
+
+
+ 采集参数
+ {configDirty && (
+
+ 待下发
+
+ )}
setConfigDirty(true)} />
- {busy ? : 下 发 配 置}
+ {busy
+ ?
+ : 下 发 配 置}
-
+
);
}
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#0d0d0d' },
- notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
- notConnectedText: { color: '#666', fontSize: 16 },
- connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
- connectBtnText: { color: '#fff', fontWeight: '600' },
- ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
+const S = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#090912' },
+ notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, backgroundColor: '#090912' },
+ notConnectedIcon: { fontSize: 48, color: '#2a2a4a' },
+ notConnectedText: { color: '#4a4a6a', fontSize: 15 },
+ goConnectBtn: { backgroundColor: '#111120', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1, borderColor: '#4a9eff' },
+ goConnectBtnText: { color: '#4a9eff', fontWeight: '600' },
+
+ // Control buttons
+ ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
ctrlBtn: {
flex: 1,
- paddingVertical: 12,
- borderRadius: 8,
+ paddingVertical: 14,
+ borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
+ gap: 4,
+ borderWidth: 1,
},
- btnStart: { backgroundColor: '#1a5c2a' },
- btnSingle: { backgroundColor: '#1a3a5c' },
- btnStop: { backgroundColor: '#5c1a1a' },
- btnDisabled: { opacity: 0.4 },
- ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
- frameInfo: {
- marginHorizontal: 12,
- backgroundColor: '#181818',
- borderRadius: 8,
- padding: 8,
- marginBottom: 8,
- },
- frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
- formScroll: { flex: 1 },
- formHeader: {
+ btnStart: { backgroundColor: '#0d2018', borderColor: '#1a4a28' },
+ btnSingle: { backgroundColor: '#0d1a30', borderColor: '#1a2e54' },
+ btnStop: { backgroundColor: '#1e0d12', borderColor: '#3a1520' },
+ btnDisabled: { opacity: 0.3 },
+ ctrlIcon: { fontSize: 12, color: '#ffffff88' },
+ ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
+ ctrlTextGreen: { color: '#3ddc84' },
+ ctrlTextBlue: { color: '#4a9eff' },
+ ctrlTextRed: { color: '#ff5c6e' },
+
+ // Frame info bar
+ frameBar: {
flexDirection: 'row',
- alignItems: 'center',
- gap: 10,
- paddingHorizontal: 14,
- paddingVertical: 8,
- },
- formTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600' },
- dirtyBadge: {
- backgroundColor: '#5c3a0a',
- color: '#ffaa00',
- fontSize: 10,
- paddingHorizontal: 6,
- paddingVertical: 2,
- borderRadius: 4,
- },
- setupBtn: {
- margin: 14,
- backgroundColor: '#4a9eff',
+ marginHorizontal: 12,
+ marginBottom: 8,
+ backgroundColor: '#0c0c1a',
borderRadius: 10,
- paddingVertical: 12,
- alignItems: 'center',
+ borderWidth: 1,
+ borderColor: '#1a1a2a',
+ overflow: 'hidden',
},
- setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
+ frameItem: { flex: 1, alignItems: 'center', paddingVertical: 8 },
+ frameItemLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
+ frameItemValue: { color: '#8888aa', fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ frameSep: { width: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 6 },
+
+ // Form
+ formScroll: { flex: 1 },
+ formHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10 },
+ formTitle: { color: '#4a4a6a', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' },
+ dirtyBadge: { backgroundColor: '#2a1a05', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1, borderColor: '#4a2a05' },
+ dirtyBadgeText: { color: '#ffb84d', fontSize: 9, fontWeight: '700' },
+
+ setupBtn: {
+ margin: 16,
+ backgroundColor: '#111120',
+ borderRadius: 12,
+ paddingVertical: 14,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: '#4a9eff',
+ },
+ setupBtnText: { color: '#4a9eff', fontWeight: '700', fontSize: 14, letterSpacing: 3 },
+
+ contextBar: { paddingHorizontal: 16, paddingVertical: 5, backgroundColor: '#08080f', borderBottomWidth: 1, borderBottomColor: '#141422' },
+ contextText: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 6cbee6d..ab3de75 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,31 +1,5 @@
-import { StyleSheet } from 'react-native';
+import { Redirect } from 'expo-router';
-import EditScreenInfo from '@/components/EditScreenInfo';
-import { Text, View } from '@/components/Themed';
-
-export default function TabOneScreen() {
- return (
-
- Tab One
-
-
-
- );
+export default function TabIndex() {
+ return ;
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
- title: {
- fontSize: 20,
- fontWeight: 'bold',
- },
- separator: {
- marginVertical: 30,
- height: 1,
- width: '80%',
- },
-});
diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx
index 462304a..7285f71 100644
--- a/app/(tabs)/map.tsx
+++ b/app/(tabs)/map.tsx
@@ -1,10 +1,12 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import MapView, { Marker, Polyline, type Region } from 'react-native-maps';
import { useDataStore } from '../../src/stores/dataStore';
import { formatCoord } from '../../src/utils/format';
export default function MapScreen() {
+ const insets = useSafeAreaInsets();
const history = useDataStore((s) => s.history);
const mapRef = useRef(null);
@@ -58,8 +60,8 @@ export default function MapScreen() {
))}
- {/* Overlay info */}
-
+ {/* Overlay info — offset by safe area top */}
+
测点数: {points.length}
{latest && (
@@ -68,8 +70,8 @@ export default function MapScreen() {
)}
- {/* Center button */}
-
+ {/* Center button — offset by safe area bottom */}
+
⌖
@@ -89,7 +91,6 @@ const styles = StyleSheet.create({
},
overlay: {
position: 'absolute',
- top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
@@ -99,7 +100,6 @@ const styles = StyleSheet.create({
overlayText: { color: '#ddd', fontSize: 11 },
centerBtn: {
position: 'absolute',
- bottom: 24,
right: 16,
width: 44,
height: 44,
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
new file mode 100644
index 0000000..a67a354
--- /dev/null
+++ b/app/(tabs)/profile.tsx
@@ -0,0 +1,698 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+ View, Text, StyleSheet, TouchableOpacity, Modal, FlatList,
+ ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
+} from 'react-native';
+import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
+import { useDataStore } from '../../src/stores/dataStore';
+import { useDeviceStore } from '../../src/stores/deviceStore';
+import * as StorageService from '../../src/services/StorageService';
+import * as BinLoader from '../../src/services/BinLoader';
+import type { MeasurementFrame } from '../../src/protocol/types';
+import type { SessionInfo } from '../../src/services/StorageService';
+import { CHANNEL_COLORS } from '../../src/protocol/constants';
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+const CP = { top: 14, right: 14, bottom: 34, left: 52 };
+const CANVAS_H = 70;
+
+const SAMPLE_FREQ_HZ: Record = {
+ 0x00: 250000, 0x01: 125000, 0x02: 62500, 0x03: 31250,
+ 0x04: 15600, 0x05: 7800, 0x06: 3900, 0x07: 1950,
+ 0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
+};
+
+const JET_HEX: string[] = Array.from({ length: 256 }, (_, i) => {
+ const t = i / 255;
+ const r = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 3)) * 255)));
+ const g = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 2)) * 255)));
+ const b = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 1)) * 255)));
+ const h = (v: number) => v.toString(16).padStart(2, '0');
+ return `#${h(r)}${h(g)}${h(b)}`;
+});
+
+// ── Gate Config ────────────────────────────────────────────────────────────
+
+import type { GateConfig, GateSpacing } from '../../src/protocol/types';
+
+function computeGatePositions(cfg: GateConfig, sampleDepth: number, hz: number): number[] {
+ if (sampleDepth === 0 || hz === 0 || cfg.count <= 0 || cfg.tStart >= cfg.tEnd) return [];
+ const totalUs = (sampleDepth / hz) * 1e6;
+ return Array.from({ length: cfg.count }, (_, i) => {
+ const t = cfg.count === 1 ? 0.5 : i / (cfg.count - 1);
+ const tUs = cfg.spacing === 'log'
+ ? cfg.tStart * Math.pow(cfg.tEnd / cfg.tStart, t)
+ : cfg.tStart + t * (cfg.tEnd - cfg.tStart);
+ return Math.max(0.001, Math.min(0.999, tUs / totalUs));
+ });
+}
+
+function gateColor(idx: number, total: number): string {
+ const t = total <= 1 ? 0.5 : idx / (total - 1);
+ return JET_HEX[Math.round(t * 255)];
+}
+
+function fmtUs(us: number): string {
+ if (us >= 1000) return `${(us / 1000).toFixed(2)}ms`;
+ return `${us.toFixed(0)}μs`;
+}
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+interface LoadedFrame {
+ frameId: number;
+ adcUV: Float64Array[];
+ sampleDepth: number;
+ channelNum: number;
+}
+
+// ── Utility ────────────────────────────────────────────────────────────────
+
+function niceStep(range: number, count: number): number {
+ const rough = range / count;
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
+ for (const f of [1, 2, 2.5, 5, 10]) if (f * mag >= rough) return f * mag;
+ return 10 * mag;
+}
+
+function formatUVShort(uv: number): string {
+ const a = Math.abs(uv);
+ if (a >= 1e6) return `${(uv / 1e6).toFixed(1)}V`;
+ if (a >= 1e3) return `${(uv / 1e3).toFixed(1)}mV`;
+ if (a >= 1) return `${uv.toFixed(0)}μV`;
+ return `${(uv * 1e3).toFixed(1)}nV`;
+}
+
+// ── useProfileData ─────────────────────────────────────────────────────────
+
+function useProfileData(sessionId: string, currentSessionId: string, history: MeasurementFrame[]) {
+ const [histFrames, setHistFrames] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [progress, setProgress] = useState({ current: 0, total: 0 });
+
+ const liveFrames = useMemo(() => {
+ if (sessionId !== currentSessionId) return [];
+ return [...history].reverse().map(f => ({
+ frameId: f.frameId,
+ adcUV: f.adcUV,
+ sampleDepth: f.adcUV[0]?.length ?? 0,
+ channelNum: f.adcUV.length,
+ }));
+ }, [sessionId, currentSessionId, history]);
+
+ useEffect(() => {
+ if (sessionId === currentSessionId) { setHistFrames([]); return; }
+ setLoading(true);
+ setHistFrames([]);
+ let cancelled = false;
+
+ (async () => {
+ const persisted = await StorageService.loadSessionFrames(sessionId);
+ setProgress({ current: 0, total: persisted.length });
+ const result: LoadedFrame[] = [];
+ for (let i = 0; i < persisted.length; i++) {
+ if (cancelled) return;
+ const pf = persisted[i];
+ if (pf.binPath) {
+ const loaded = await BinLoader.loadBinFile(pf.binPath);
+ if (loaded) result.push({ ...loaded, frameId: pf.frameId });
+ }
+ setProgress({ current: i + 1, total: persisted.length });
+ }
+ if (!cancelled) { setHistFrames(result); setLoading(false); }
+ })();
+
+ return () => { cancelled = true; };
+ }, [sessionId, currentSessionId]);
+
+ return {
+ frames: sessionId === currentSessionId ? liveFrames : histFrames,
+ loading: sessionId !== currentSessionId && loading,
+ progress,
+ };
+}
+
+// ── GateSettingsModal ──────────────────────────────────────────────────────
+
+function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz }: {
+ visible: boolean;
+ config: GateConfig;
+ onApply: (cfg: GateConfig) => void;
+ onClose: () => void;
+ sampleDepth: number;
+ hz: number;
+}) {
+ const [tStartStr, setTStartStr] = useState(String(config.tStart));
+ const [tEndStr, setTEndStr] = useState(String(config.tEnd));
+ const [countStr, setCountStr] = useState(String(config.count));
+ const [spacing, setSpacing] = useState(config.spacing);
+
+ useEffect(() => {
+ if (visible) {
+ setTStartStr(String(config.tStart));
+ setTEndStr(String(config.tEnd));
+ setCountStr(String(config.count));
+ setSpacing(config.spacing);
+ }
+ }, [visible, config]);
+
+ const maxUs = sampleDepth > 0 && hz > 0 ? (sampleDepth / hz) * 1e6 : 100000;
+
+ const handleApply = () => {
+ const tStart = Math.max(1, parseFloat(tStartStr) || config.tStart);
+ const tEnd = Math.min(Math.max(tStart + 1, parseFloat(tEndStr) || config.tEnd), maxUs * 0.999);
+ const count = Math.max(1, Math.min(64, parseInt(countStr, 10) || config.count));
+ onApply({ tStart, tEnd, count, spacing });
+ onClose();
+ };
+
+ return (
+
+
+
+ 时间门配置
+
+
+
+ 起始时间 (μs)
+
+
+
+ 终止时间 (μs)
+
+
+
+
+
+
+ 门数量
+
+
+
+ 间隔方式
+
+ {(['log', 'linear'] as GateSpacing[]).map(s => (
+ setSpacing(s)}
+ >
+
+ {s === 'log' ? '对数' : '线性'}
+
+
+ ))}
+
+
+
+
+ {sampleDepth > 0 && hz > 0 && (
+ 当前采样窗口: {fmtUs(maxUs)}
+ )}
+
+
+
+ 取消
+
+
+ 应用
+
+
+
+
+ );
+}
+
+const GSM = StyleSheet.create({
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
+ sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
+ title: { color: '#8888bb', fontSize: 14, fontWeight: '700', marginBottom: 16 },
+ row: { flexDirection: 'row', gap: 12 },
+ field: { flex: 1, gap: 4 },
+ label: { color: '#4a4a6a', fontSize: 11, fontWeight: '600' },
+ input: { backgroundColor: '#111120', borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', color: '#9090b8', fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
+ toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: '#2a2a3a' },
+ toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center', backgroundColor: '#111120' },
+ toggleBtnOn: { backgroundColor: '#0d2040' },
+ toggleTxt: { color: '#4a4a6a', fontSize: 12, fontWeight: '700' },
+ toggleTxtOn: { color: '#4a9eff' },
+ hint: { color: '#2a2a4a', fontSize: 10, marginTop: 8 },
+ actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
+ cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
+ cancelTxt: { color: '#4a4a6a', fontSize: 13, fontWeight: '600' },
+ applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, backgroundColor: '#0d2040', borderWidth: 1, borderColor: '#4a9eff55', alignItems: 'center' },
+ applyTxt: { color: '#4a9eff', fontSize: 13, fontWeight: '700' },
+});
+
+// ── GatePreview ────────────────────────────────────────────────────────────
+
+const GP = { t: 6, r: 8, b: 6, l: 8 };
+
+function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }: {
+ refFrame: LoadedFrame | null;
+ channelIdx: number;
+ gatePositions: number[];
+ gateColors: string[];
+ width: number;
+}) {
+ const plotW = width - GP.l - GP.r;
+ const plotH = CANVAS_H - GP.t - GP.b;
+
+ const wavePath = useMemo(() => {
+ if (!refFrame) return null;
+ const ch = refFrame.adcUV[channelIdx];
+ if (!ch || ch.length === 0) return null;
+ const n = Math.min(ch.length, 256);
+ let logMin = Infinity, logMax = -Infinity;
+ for (let i = 0; i < n; i++) {
+ const a = Math.abs(ch[Math.floor(i * ch.length / n)]);
+ if (a > 1e-9) { const l = Math.log10(a); if (l < logMin) logMin = l; if (l > logMax) logMax = l; }
+ }
+ if (!isFinite(logMin)) return null;
+ const logR = Math.max(logMax - logMin, 0.5);
+ const path = Skia.Path.Make();
+ let moved = false;
+ for (let i = 0; i < n; i++) {
+ const v = ch[Math.floor(i * ch.length / n)];
+ const a = Math.abs(v);
+ if (a < 1e-12) continue;
+ const x = GP.l + (i / (n - 1)) * plotW;
+ const y = GP.t + plotH - ((Math.log10(a) - logMin) / logR) * plotH;
+ if (!isFinite(y)) continue;
+ if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
+ }
+ return path;
+ }, [refFrame, channelIdx, plotW, plotH]);
+
+ return (
+
+ );
+}
+
+// ── GateLineChart ──────────────────────────────────────────────────────────
+
+function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale, width, height }: {
+ frames: LoadedFrame[]; channelIdx: number;
+ gatePositions: number[]; gateColors: string[];
+ logScale: boolean; width: number; height: number;
+}) {
+ const plotW = width - CP.left - CP.right;
+ const plotH = height - CP.top - CP.bottom;
+
+ const derived = useMemo(() => {
+ const sd = frames[0]?.sampleDepth ?? 0;
+
+ const series = gatePositions.map(pos => {
+ if (sd === 0) return null;
+ const si = Math.min(sd - 1, Math.floor(pos * sd));
+ return frames.map(f => f.adcUV[channelIdx]?.[si] ?? 0);
+ });
+
+ let vMin = Infinity, vMax = -Infinity;
+ for (const s of series) {
+ if (!s) continue;
+ for (const v of s) {
+ if (Math.abs(v) < 1e-9) continue;
+ const val = logScale ? Math.log10(Math.abs(v)) : v;
+ if (val < vMin) vMin = val;
+ if (val > vMax) vMax = val;
+ }
+ }
+ if (!isFinite(vMin)) { vMin = logScale ? -3 : -1; vMax = logScale ? 6 : 1; }
+ const pad = (vMax - vMin) * 0.05;
+ const yMin = vMin - pad, yMax = vMax + pad;
+ const yRange = Math.max(yMax - yMin, 1e-9);
+
+ const toY = (v: number) => {
+ const m = logScale ? (Math.abs(v) < 1e-12 ? yMin : Math.log10(Math.abs(v))) : v;
+ return CP.top + plotH - ((m - yMin) / yRange) * plotH;
+ };
+ const toX = (i: number) => CP.left + (i / Math.max(frames.length - 1, 1)) * plotW;
+
+ const paths = series.map((s) => {
+ if (!s) return null;
+ const path = Skia.Path.Make();
+ let moved = false;
+ for (let i = 0; i < s.length; i++) {
+ if (logScale && Math.abs(s[i]) < 1e-12) continue;
+ const x = toX(i), y = toY(s[i]);
+ if (!isFinite(y)) continue;
+ if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
+ }
+ return path;
+ });
+
+ let yTicks: number[];
+ if (logScale) {
+ yTicks = [-1, 0, 1, 2, 3, 4, 5, 6, 7]
+ .filter(e => e >= yMin && e <= yMax)
+ .map(e => Math.pow(10, e));
+ } else {
+ const step = niceStep(yMax - yMin, 5);
+ yTicks = [];
+ for (let v = Math.ceil(yMin / step) * step; v <= yMax * 1.001; v += step) yTicks.push(v);
+ }
+
+ const xTicks: number[] = [];
+ if (frames.length > 2) {
+ const step = Math.max(1, Math.round(niceStep(frames.length - 1, 5)));
+ for (let xi = step; xi < frames.length; xi += step) xTicks.push(xi);
+ }
+
+ return { paths, yTicks, xTicks, toY, toX };
+ }, [frames, channelIdx, gatePositions, logScale, plotW, plotH]);
+
+ const { paths, yTicks, xTicks, toY, toX } = derived;
+ const left = CP.left, right = CP.left + plotW;
+ const top = CP.top, bot = CP.top + plotH;
+
+ return (
+
+
+ {yTicks.map((v, idx) => {
+ const y = toY(v);
+ if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
+ const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v);
+ return {label};
+ })}
+ {xTicks.map((i, idx) => (
+ {i}
+ ))}
+ μV
+ 帧
+
+ );
+}
+
+const LBL = StyleSheet.create({
+ y: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'right' },
+ x: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'center' },
+});
+
+// ── SessionPickerModal ─────────────────────────────────────────────────────
+
+function SessionPickerModal({ visible, sessions, allSessions, currentId, selectedId, onSelect, onClose }: {
+ visible: boolean; sessions: SessionInfo[]; allSessions: SessionInfo[];
+ currentId: string; selectedId: string;
+ onSelect: (id: string) => void; onClose: () => void;
+}) {
+ const [showAll, setShowAll] = useState(false);
+ const source = showAll ? allSessions : sessions;
+
+ const listData = useMemo(() => {
+ const currentEntry = { sessionId: currentId, createdAt: 0, frameCount: -1, projectId: null };
+ const rest = source.filter(s => s.sessionId !== currentId);
+ return [currentEntry, ...rest];
+ }, [source, currentId]);
+
+ return (
+
+
+
+
+ 选择测线
+ setShowAll(v => !v)}>
+ {showAll ? '全部' : '当前工程'}
+
+
+ item.sessionId}
+ style={SPM.list}
+ renderItem={({ item }) => {
+ const isCur = item.sessionId === currentId;
+ const isSel = item.sessionId === selectedId;
+ return (
+ { onSelect(item.sessionId); onClose(); }}
+ >
+
+ {item.sessionId}{isCur ? ' (当前)' : ''}
+
+ {item.frameCount >= 0 && (
+ {item.frameCount} 帧
+ )}
+
+ );
+ }}
+ />
+
+
+ );
+}
+
+const SPM = StyleSheet.create({
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' },
+ sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, maxHeight: '55%' },
+ header: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 8 },
+ title: { flex: 1, color: '#6a6a8a', fontSize: 12, fontWeight: '700' },
+ allBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#0e0e1c' },
+ allBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
+ allTxt: { color: '#555', fontSize: 10, fontWeight: '700' },
+ allTxtOn: { color: '#4a9eff' },
+ list: { maxHeight: 280 },
+ item: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
+ itemSel: { backgroundColor: '#111130' },
+ itemId: { color: '#8888aa', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
+ itemCnt: { color: '#4a4a6a', fontSize: 11, marginLeft: 8 },
+});
+
+// ── ProfileScreen ──────────────────────────────────────────────────────────
+
+export default function ProfileScreen() {
+ const { width: sw, height: sh } = useWindowDimensions();
+ const { history, sessionId: currentSessionId, projectId } = useDataStore();
+ const sampleFreqCode = useDeviceStore(s => s.config.sampleFreq);
+
+ const gateConfig = useDeviceStore(s => s.gateConfig);
+ const setGateConfig = useDeviceStore(s => s.setGateConfig);
+
+ const [channelIdx, setChannelIdx] = useState(0);
+ const [sessionId, setSessionId] = useState(currentSessionId);
+ const [projectSessions, setProjectSessions] = useState([]);
+ const [allSessions, setAllSessions] = useState([]);
+ const [pickerVisible, setPickerVisible] = useState(false);
+ const [settingsVisible, setSettingsVisible] = useState(false);
+ const [logScale, setLogScale] = useState(true);
+
+ const { frames, loading, progress } = useProfileData(sessionId, currentSessionId, history);
+ const CHART_H = Math.round(Math.min(300, sh * 0.42));
+
+ useEffect(() => {
+ const load = async () => {
+ const all = await StorageService.listSessions();
+ setAllSessions(all.filter(x => x.sessionId !== currentSessionId));
+ if (projectId) {
+ const proj = await StorageService.listSessionsByProject(projectId);
+ setProjectSessions(proj.filter(x => x.sessionId !== currentSessionId));
+ } else {
+ setProjectSessions(all.filter(x => x.sessionId !== currentSessionId));
+ }
+ };
+ load();
+ }, [currentSessionId, projectId]);
+
+ const maxCh = frames[0]?.channelNum ?? 6;
+ useEffect(() => { if (channelIdx >= maxCh) setChannelIdx(0); }, [maxCh, channelIdx]);
+
+ const hz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
+ const sampleDepth = frames[0]?.sampleDepth ?? 0;
+
+ const gatePositions = useMemo(
+ () => computeGatePositions(gateConfig, sampleDepth, hz),
+ [gateConfig, sampleDepth, hz],
+ );
+ const gateColors = useMemo(
+ () => Array.from({ length: gateConfig.count }, (_, i) => gateColor(i, gateConfig.count)),
+ [gateConfig.count],
+ );
+
+ const refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]);
+
+ return (
+
+
+ {/* ── Header ── */}
+
+ setPickerVisible(true)}>
+ {sessionId}
+ ▾
+
+ setLogScale(v => !v)}>
+ {logScale ? 'LOG' : 'LIN'}
+
+
+
+ {/* ── Channel selector ── */}
+
+ {Array.from({ length: maxCh }, (_, i) => (
+ setChannelIdx(i)}
+ >
+
+ CH{i + 1}
+
+
+ ))}
+
+
+ {/* ── Loading progress ── */}
+ {loading && (
+
+
+ 加载 {progress.current}/{progress.total} 帧…
+
+ )}
+
+ {/* ── Empty ── */}
+ {!loading && frames.length === 0 && (
+
+ 暂无剖面数据
+
+ )}
+
+ {/* ── Charts ── */}
+ {frames.length > 0 && (
+
+
+
+
+ {/* Gate preview + legend + settings */}
+
+
+
+ 时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
+
+ setSettingsVisible(true)}>
+ ⚙ 配置
+
+
+
+
+
+
+
+ {gatePositions.map((pos, gi) => {
+ const tUs = sampleDepth > 0 && hz > 0 ? pos * (sampleDepth / hz) * 1e6 : 0;
+ return (
+
+
+ G{gi + 1} {fmtUs(tUs)}
+
+ );
+ })}
+
+
+
+
+
+ )}
+
+ setPickerVisible(false)}
+ />
+
+ setSettingsVisible(false)}
+ sampleDepth={sampleDepth}
+ hz={hz}
+ />
+
+ );
+}
+
+const SCR = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#0d0d0d' },
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
+ sessBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#111', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#222' },
+ sessBtnTxt: { flex: 1, color: '#6a9eff', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ sessBtnArrow: { color: '#555', fontSize: 10, marginLeft: 4 },
+ scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a1a' },
+ scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
+ scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
+ scaleTxtOn: { color: '#4a9eff' },
+ chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
+ chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a2a' },
+ chTxt: { color: '#555', fontSize: 11, fontWeight: '600' },
+ loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
+ loadTxt: { color: '#556', fontSize: 12 },
+ empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
+ emptyTxt: { color: '#333', fontSize: 14 },
+
+ gateSection: { backgroundColor: '#111', marginTop: 2 },
+ gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
+ gateHdr: { flex: 1, color: '#444', fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
+ settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a3a5a', backgroundColor: '#0a1428' },
+ settingsTxt: { color: '#4a7abf', fontSize: 11, fontWeight: '600' },
+
+ legendScroll: { paddingHorizontal: 12, paddingVertical: 8 },
+ legendRow: { flexDirection: 'row', gap: 10 },
+ legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ legendDot: { width: 8, height: 8, borderRadius: 4 },
+ legendTxt: { color: '#668', fontSize: 10 },
+});
diff --git a/app/(tabs)/projects.tsx b/app/(tabs)/projects.tsx
new file mode 100644
index 0000000..104e24c
--- /dev/null
+++ b/app/(tabs)/projects.tsx
@@ -0,0 +1,545 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ View, Text, FlatList, TouchableOpacity, StyleSheet,
+ Alert, TextInput, Modal, ActivityIndicator, Platform,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useDataStore } from '../../src/stores/dataStore';
+import { useDeviceStore } from '../../src/stores/deviceStore';
+import * as StorageService from '../../src/services/StorageService';
+import { shareFile } from '../../src/utils/export';
+import type { ProjectInfo, SessionInfo } from '../../src/services/StorageService';
+import * as DocumentPicker from 'expo-document-picker';
+
+// ── Name input modal (reused for create project / rename) ────────────────────
+
+function NameModal({
+ visible,
+ title,
+ placeholder,
+ initial,
+ onConfirm,
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ placeholder: string;
+ initial?: string;
+ onConfirm: (name: string) => void;
+ onClose: () => void;
+}) {
+ const [text, setText] = useState(initial ?? '');
+ useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]);
+
+ return (
+
+
+
+ {title}
+
+
+
+ 取消
+
+ { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
+ disabled={!text.trim()}
+ >
+ 确定
+
+
+
+
+ );
+}
+
+const NM = StyleSheet.create({
+ backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
+ box: { position: 'absolute', left: 24, right: 24, top: '35%', backgroundColor: '#111120', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#22223a' },
+ title: { color: '#9090b8', fontSize: 14, fontWeight: '700', marginBottom: 14 },
+ input: { backgroundColor: '#0c0c18', borderRadius: 8, padding: 12, color: '#d0d0e8', fontSize: 14, borderWidth: 1, borderColor: '#22223a', marginBottom: 16 },
+ row: { flexDirection: 'row', gap: 10 },
+ cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
+ cancelTxt: { color: '#6a6a8a', fontWeight: '600' },
+ confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, backgroundColor: '#1a3a6e', borderWidth: 1, borderColor: '#4a9eff', alignItems: 'center' },
+ confirmTxt: { color: '#4a9eff', fontWeight: '700' },
+ btnDis: { opacity: 0.4 },
+});
+
+// ── Line list panel (shown when a project is expanded) ───────────────────────
+
+function LineList({
+ project,
+ currentSessionId,
+ currentProjectId,
+ onActivate,
+ onExport,
+ onDelete,
+}: {
+ project: ProjectInfo;
+ currentSessionId: string;
+ currentProjectId: string | null;
+ onActivate: (sessionId: string) => void;
+ onExport: (sessionId: string) => void;
+ onDelete: (sessionId: string) => void;
+}) {
+ const [lines, setLines] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const reload = useCallback(async () => {
+ setLoading(true);
+ const data = await StorageService.listSessionsByProject(project.projectId);
+ setLines(data);
+ setLoading(false);
+ }, [project.projectId]);
+
+ useEffect(() => { void reload(); }, [reload]);
+
+ if (loading) return ;
+ if (lines.length === 0) {
+ return 暂无测线;
+ }
+
+ return (
+
+ {lines.map((line) => {
+ const isActive = line.sessionId === currentSessionId;
+ const date = new Date(line.createdAt);
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
+ return (
+
+
+ {isActive && }
+
+ {line.sessionId}
+ {dateStr} · {line.frameCount} 测点
+
+
+
+ {!isActive && (
+ onActivate(line.sessionId)}>
+ 激活
+
+ )}
+ onExport(line.sessionId)}>
+ 导出
+
+ {!isActive && (
+ onDelete(line.sessionId)}>
+ 删除
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
+const LL = StyleSheet.create({
+ wrap: { backgroundColor: '#08080f', marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
+ empty: { color: '#2a2a4a', fontSize: 12, textAlign: 'center', paddingVertical: 14 },
+ row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#141422', gap: 8 },
+ rowActive: { backgroundColor: '#0d1a2e' },
+ rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
+ activeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9eff' },
+ lineId: { color: '#6a6a9a', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ lineSub: { color: '#2a2a4a', fontSize: 10, marginTop: 1 },
+ actions: { flexDirection: 'row', gap: 6 },
+ btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
+ actBtn: { borderColor: '#1a3a60', backgroundColor: '#0a1830' },
+ actTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700' },
+ expBtn: { borderColor: '#1a4a28', backgroundColor: '#0a1810' },
+ expTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' },
+ delBtn: { borderColor: '#3a1520', backgroundColor: '#140a0c' },
+ delTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' },
+});
+
+// ── Main screen ──────────────────────────────────────────────────────────────
+
+export default function ProjectsScreen() {
+ const insets = useSafeAreaInsets();
+ const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject } = useDataStore();
+ const { config: deviceConfig, gateConfig } = useDeviceStore();
+
+ const [projects, setProjects] = useState([]);
+ const [expanded, setExpanded] = useState(null);
+ const [exporting, setExporting] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [progress, setProgress] = useState({ current: 0, total: 0 });
+
+ // Name modal state
+ const [modal, setModal] = useState<{
+ visible: boolean;
+ title: string;
+ placeholder: string;
+ initial?: string;
+ onConfirm: (name: string) => void;
+ }>({ visible: false, title: '', placeholder: '', onConfirm: () => {} });
+
+ const reload = useCallback(async () => {
+ const data = await StorageService.listProjects();
+ setProjects(data);
+ }, []);
+
+ useEffect(() => { void reload(); }, [reload]);
+
+ const openModal = (opts: typeof modal) => setModal({ ...opts, visible: true });
+ const closeModal = () => setModal((m) => ({ ...m, visible: false }));
+
+ const handleCreateProject = () => {
+ openModal({
+ visible: true,
+ title: '新建工程',
+ placeholder: '输入工程名称',
+ onConfirm: async (name) => {
+ const id = await StorageService.createProject(name);
+ await reload();
+ setExpanded(id);
+ },
+ });
+ };
+
+ const handleRenameProject = (project: ProjectInfo) => {
+ openModal({
+ visible: true,
+ title: '重命名工程',
+ placeholder: '新名称',
+ initial: project.name,
+ onConfirm: async (name) => {
+ await StorageService.renameProject(project.projectId, name);
+ await reload();
+ },
+ });
+ };
+
+ const handleDeleteProject = (project: ProjectInfo) => {
+ Alert.alert(
+ '删除工程',
+ `确定删除工程「${project.name}」?\n测线数据将保留,但与工程的关联会断开。`,
+ [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除', style: 'destructive',
+ onPress: async () => {
+ await StorageService.deleteProject(project.projectId);
+ if (currentProjectId === project.projectId) await setProject(null);
+ await reload();
+ },
+ },
+ ],
+ );
+ };
+
+ const handleNewLine = async (project: ProjectInfo) => {
+ await newSession(project.projectId);
+ await reload();
+ };
+
+ // Open project: resume latest line or create a new one.
+ const handleOpenProject = async (project: ProjectInfo) => {
+ const lines = await StorageService.listSessionsByProject(project.projectId);
+ const latest = lines[0]; // sorted DESC by created_at
+
+ if (!latest) {
+ await handleNewLine(project);
+ return;
+ }
+
+ Alert.alert(
+ `打开工程「${project.name}」`,
+ `最近测线: ${latest.sessionId}\n${latest.frameCount} 测点`,
+ [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '新建测线',
+ onPress: () => handleNewLine(project),
+ },
+ {
+ text: '继续最近测线',
+ onPress: async () => {
+ await resumeSession(latest.sessionId, project.projectId);
+ await reload();
+ },
+ },
+ ],
+ );
+ };
+
+ const handleActivateLine = (sessionId: string, projectId: string) => {
+ Alert.alert(
+ '打开测线',
+ '将切换到该测线继续采集,当前数据已保存。',
+ [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '打开',
+ onPress: async () => {
+ await resumeSession(sessionId, projectId);
+ await reload();
+ },
+ },
+ ],
+ );
+ };
+
+ const handleExportLine = async (sessionId: string) => {
+ setExporting(sessionId);
+ try {
+ const path = await StorageService.exportSessionMetaCsv(sessionId);
+ await shareFile(path);
+ } catch (e: any) {
+ Alert.alert('导出失败', e.message);
+ } finally {
+ setExporting(null);
+ }
+ };
+
+ const handleExportProject = async (project: ProjectInfo) => {
+ setExporting(project.projectId);
+ setProgress({ current: 0, total: 0 });
+ try {
+ const path = await StorageService.exportProject(
+ project.projectId,
+ deviceConfig,
+ gateConfig,
+ (current, total) => setProgress({ current, total }),
+ );
+ await shareFile(path);
+ } catch (e: any) {
+ Alert.alert('导出失败', e.message);
+ } finally {
+ setExporting(null);
+ }
+ };
+
+ const handleImportProject = async () => {
+ try {
+ const result = await DocumentPicker.getDocumentAsync({
+ type: '*/*',
+ copyToCacheDirectory: true,
+ });
+ if (result.canceled) return;
+ const uri = result.assets[0].uri;
+
+ setImporting(true);
+ setProgress({ current: 0, total: 0 });
+
+ const imported = await StorageService.importProject(
+ uri,
+ (current, total) => setProgress({ current, total }),
+ );
+ await reload();
+ Alert.alert(
+ '导入成功',
+ `工程「${imported.projectName}」已导入\n${imported.sessionCount} 条测线 · ${imported.frameCount} 测点`,
+ );
+ } catch (e: any) {
+ Alert.alert('导入失败', e.message);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleDeleteLine = (sessionId: string) => {
+ Alert.alert('删除测线', `确定删除测线 ${sessionId} 的所有数据?`, [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除', style: 'destructive',
+ onPress: async () => {
+ await StorageService.deleteSession(sessionId);
+ await reload();
+ },
+ },
+ ]);
+ };
+
+ return (
+
+ {/* Header */}
+
+ 工程管理
+
+ {importing
+ ?
+ : 导入 .tem}
+
+
+ + 新建
+
+
+
+ {/* Progress bar (export / import) */}
+ {(importing || exporting !== null) && progress.total > 0 && (
+
+
+ {progress.current}/{progress.total} 帧
+
+ )}
+
+ {/* Current context pill */}
+
+ 当前测线
+ {currentSessionId}
+ {currentProjectId ? (
+
+
+ {projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
+
+
+ ) : (
+
+ 未分配工程
+
+ )}
+
+
+ {/* Project list */}
+ p.projectId}
+ contentContainerStyle={{ paddingBottom: 32 }}
+ ListEmptyComponent={
+
+ ◫
+ 暂无工程
+ 点击右上角「新建工程」开始
+
+ }
+ renderItem={({ item: project }) => {
+ const isExpanded = expanded === project.projectId;
+ const isCurrentProject = project.projectId === currentProjectId;
+ return (
+
+ {/* Project row */}
+ setExpanded(isExpanded ? null : project.projectId)}
+ activeOpacity={0.8}
+ >
+
+ {isExpanded ? '▾' : '▸'}
+
+
+ {project.name}
+ {isCurrentProject && (
+
+ 当前
+
+ )}
+
+
+ {project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
+
+
+
+
+ handleOpenProject(project)}>
+ 打开
+
+ handleExportProject(project)}
+ disabled={exporting === project.projectId}
+ >
+ {exporting === project.projectId
+ ?
+ : .tem}
+
+ handleRenameProject(project)}>
+ 改名
+
+ handleDeleteProject(project)}>
+ 删除
+
+
+
+
+ {/* Expanded line list */}
+ {isExpanded && (
+ handleActivateLine(sid, project.projectId)}
+ onExport={handleExportLine}
+ onDelete={handleDeleteLine}
+ />
+ )}
+
+ );
+ }}
+ />
+
+
+
+ );
+}
+
+const S = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#090912' },
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
+ headerTitle:{ color: '#6a6a9a', fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
+ addBtn: { backgroundColor: '#1a3a6e', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1, borderColor: '#4a9eff' },
+ addBtnTxt: { color: '#4a9eff', fontSize: 12, fontWeight: '700' },
+
+ contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#0c0c18', borderBottomWidth: 1, borderBottomColor: '#141422', gap: 8 },
+ contextLabel: { color: '#2a2a4a', fontSize: 10, fontWeight: '600', letterSpacing: 1 },
+ contextSession:{ color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
+ projectPill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: '#1a3a6e' },
+ projectPillTxt:{ color: '#4a9eff', fontSize: 9, fontWeight: '700' },
+ projectPillNone: { backgroundColor: '#1a1a1a', borderColor: '#2a2a2a' },
+ projectPillTxtNone:{ color: '#3a3a5a' },
+
+ empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
+ emptyIcon: { fontSize: 40, color: '#1a1a2a' },
+ emptyTxt: { color: '#2a2a4a', fontSize: 15 },
+ emptyHint: { color: '#1a1a2a', fontSize: 12 },
+
+ projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', overflow: 'hidden', backgroundColor: '#0e0e1c' },
+
+ projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 },
+ projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
+ chevron: { color: '#3a3a5a', fontSize: 12, width: 12 },
+ projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 },
+ projectName: { color: '#c0c0d8', fontSize: 14, fontWeight: '700' },
+ projectMeta: { color: '#3a3a5a', fontSize: 10, marginTop: 2 },
+ activePill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1, borderColor: '#4a9eff55' },
+ activePillTxt: { color: '#4a9eff', fontSize: 8, fontWeight: '700' },
+
+ importBtn: { backgroundColor: '#1e1a08', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: '#ffd93d66', marginRight: 8, minWidth: 70, alignItems: 'center' },
+ importBtnTxt: { color: '#ffd93d', fontSize: 12, fontWeight: '700' },
+ btnDis: { opacity: 0.4 },
+
+ progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, backgroundColor: '#0c0c18', borderRadius: 10, overflow: 'hidden', borderWidth: 1, borderColor: '#1a1a2a', justifyContent: 'center' },
+ progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, backgroundColor: '#1a3a6e', borderRadius: 10 },
+ progressTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700', textAlign: 'center' },
+
+ projectActions:{ flexDirection: 'row', gap: 5 },
+ pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#111120', alignItems: 'center', justifyContent: 'center' },
+ pBtnTxt: { color: '#5a5a7a', fontSize: 10, fontWeight: '600' },
+ pBtnNew: { borderColor: '#1a4a28', backgroundColor: '#0a1810' },
+ pBtnNewTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' },
+ pBtnExp: { borderColor: '#4a3a1a', backgroundColor: '#1a1408' },
+ pBtnExpTxt: { color: '#ffd93d', fontSize: 10, fontWeight: '700' },
+ pBtnDel: { borderColor: '#3a1520', backgroundColor: '#140a0c' },
+ pBtnDelTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' },
+});
diff --git a/app/(tabs)/records.tsx b/app/(tabs)/records.tsx
index 5223e60..80fd093 100644
--- a/app/(tabs)/records.tsx
+++ b/app/(tabs)/records.tsx
@@ -1,28 +1,113 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo } from 'react';
import {
- View,
- Text,
- FlatList,
- TouchableOpacity,
- StyleSheet,
- Alert,
- ActivityIndicator,
+ View, Text, FlatList, TouchableOpacity, Modal,
+ StyleSheet, Alert, ActivityIndicator, Platform,
+ useWindowDimensions, ScrollView,
} from 'react-native';
-import { router } from 'expo-router';
import { useDataStore } from '../../src/stores/dataStore';
+import { useDeviceStore } from '../../src/stores/deviceStore';
import { exportCsv, shareFile } from '../../src/utils/export';
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
+import { CHANNEL_COLORS } from '../../src/protocol/constants';
+import { WaveformChart } from '../../src/components/WaveformChart';
+import { computeWaveformData } from '../../src/hooks/useWaveform';
import type { MeasurementFrame } from '../../src/protocol/types';
+// ── Waveform modal ──────────────────────────────────────────────────────────
+
+function FrameWaveformModal({
+ frame,
+ onClose,
+}: {
+ frame: MeasurementFrame;
+ onClose: () => void;
+}) {
+ const { width } = useWindowDimensions();
+ const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
+ const [logScale, setLogScale] = useState(true);
+
+ const visible = useMemo(
+ () => Array(frame.meta?.channelNum ?? frame.adcUV.length).fill(true),
+ [frame],
+ );
+ const data = useMemo(
+ () => computeWaveformData(frame, visible, sampleFreqCode),
+ [frame, visible, sampleFreqCode],
+ );
+
+ const CHART_H = Math.min(260, Math.round(width * 0.6));
+
+ return (
+
+
+
+
+ 帧 #{frame.frameId}
+ setLogScale((v) => !v)}
+ >
+
+ {logScale ? 'LOG' : 'LIN'}
+
+
+
+ ✕
+
+
+
+ {data ? (
+
+ ) : (
+
+ 无波形数据
+
+ )}
+
+
+ {frame.adcUV.map((ch, i) => {
+ const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
+ return (
+
+
+ CH{i + 1}
+ {formatUV(pk)}
+
+ );
+ })}
+
+
+ {frame.meta && (
+
+
+ UTC {formatUtc(frame.meta.utc)} ×{frame.accNum}叠 增益×{frame.gain}
+
+
+ {formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)}
+
+
+ )}
+
+
+
+ );
+}
+
+// ── Main screen ─────────────────────────────────────────────────────────────
+
export default function RecordsScreen() {
- const { history, sessionId, clearHistory } = useDataStore();
- const [exporting, setExporting] = useState(false);
+ const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
+ const [exporting, setExporting] = useState(false);
+ const [selectedFrame, setSelectedFrame] = useState(null);
const handleExportAll = async () => {
- if (history.length === 0) {
- Alert.alert('无数据', '当前会话没有采集记录');
- return;
- }
+ if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
setExporting(true);
try {
const path = await exportCsv(history, sessionId);
@@ -41,119 +126,216 @@ export default function RecordsScreen() {
]);
};
- const renderItem = ({ item }: { item: MeasurementFrame }) => {
- const m = item.meta;
- const peakUV = item.adcUV[0]
- ? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
- : 0;
+ const handleNewSession = () => {
+ Alert.alert('新建测线', '结束当前测线,开始新测线?', [
+ { text: '取消', style: 'cancel' },
+ { text: '新建', onPress: () => newSession(projectId) },
+ ]);
+ };
+ const handleDeleteFrame = (item: MeasurementFrame) => {
+ Alert.alert(
+ `删除测点 #${item.frameId}`,
+ '确定删除该测点?此操作不可撤销。',
+ [
+ { text: '取消', style: 'cancel' },
+ { text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) },
+ ],
+ );
+ };
+
+ const renderItem = ({ item, index }: { item: MeasurementFrame; index: number }) => {
+ const m = item.meta;
return (
-
-
- #{item.frameId}
- {m ? formatUtc(m.utc) : '--'}
+ setSelectedFrame(item)}
+ activeOpacity={0.75}
+ >
+
+ {String(history.length - index).padStart(3, '0')}
- {m && (
- <>
-
- {formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
-
-
- 叠加 {item.accNum}次
- 增益 ×{item.gain}
- CH1峰值 {formatUV(peakUV)}
- 0 ? '#1a4a1a' : '#2a1a1a' }]}>
- 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
+
+
+ #{item.frameId}
+ {m ? formatUtc(m.utc) : '--'}
+ {m && (
+ 0 ? '#0d2018' : '#1a0d10' }]}>
+ 0 ? '#3ddc84' : '#ff5c6e', fontSize: 8, fontWeight: '700' }}>
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
+ )}
+
+ 波形 ›
- >
- )}
-
+ handleDeleteFrame(item)} hitSlop={8}>
+ ×
+
+
+
+ {m && (
+
+ {formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
+
+ )}
+
+
+
+ {item.adcUV.slice(0, item.meta?.channelNum ?? 0).map((ch, i) => {
+ const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
+ return (
+
+
+ {formatUV(pk)}
+
+ );
+ })}
+
+
+ ×{item.accNum}叠
+ 增益×{item.gain}
+
+
+
+
);
};
return (
-
+
{/* Toolbar */}
-
- 会话: {sessionId}
- {history.length} 测点
-
-
- {exporting ? (
-
- ) : (
- 导出 CSV
- )}
-
-
- 清空
-
+
+
+ {sessionId}
+ {history.length} 测点
+
+ {exporting
+ ?
+ : 导出 CSV}
+
+
+ 新建
+
+
+ 清空
+
{history.length === 0 ? (
-
- 暂无采集记录
- 在控制台开始采集后,数据将显示在这里
+
+ ◌
+ 暂无采集记录
+ 在波形页开始采集后,数据将显示在这里
) : (
String(item.frameId)}
renderItem={renderItem}
- contentContainerStyle={styles.list}
- ItemSeparatorComponent={() => }
+ contentContainerStyle={S.list}
+ />
+ )}
+
+ {selectedFrame && (
+ setSelectedFrame(null)}
/>
)}
);
}
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#0d0d0d' },
+// ── Styles ──────────────────────────────────────────────────────────────────
+
+const S = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#090912' },
+
toolbar: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 10,
- backgroundColor: '#111',
- borderBottomWidth: 1,
- borderBottomColor: '#2a2a2a',
+ flexDirection: 'row', alignItems: 'center',
+ paddingHorizontal: 14, paddingVertical: 10,
+ backgroundColor: '#0c0c18',
+ borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
gap: 8,
- flexWrap: 'wrap',
},
- sessionText: { color: '#555', fontSize: 10, flex: 1 },
- countText: { color: '#888', fontSize: 12, fontWeight: '600' },
- toolbarBtns: { flexDirection: 'row', gap: 8 },
- toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
- exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
- exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
- clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
- clearText: { color: '#ff6666', fontSize: 12 },
+ toolbarLeft: { flex: 1, gap: 2 },
+ sessionId: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ count: { color: '#6a6a8a', fontSize: 13, fontWeight: '700' },
+ toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
+ exportBtn: { borderColor: '#1a4a28', backgroundColor: '#0d2018' },
+ exportText: { color: '#3ddc84', fontSize: 12, fontWeight: '700' },
+ newBtn: { borderColor: '#2a3a1a', backgroundColor: '#141e0d' },
+ newText: { color: '#aad464', fontSize: 12, fontWeight: '600' },
+ clearBtn: { borderColor: '#3a1520', backgroundColor: '#1e0d12' },
+ clearText: { color: '#ff5c6e', fontSize: 12, fontWeight: '600' },
btnDisabled: { opacity: 0.4 },
- list: { padding: 10, gap: 8 },
+
+ list: { padding: 12, gap: 6 },
+
item: {
- backgroundColor: '#181818',
- borderRadius: 8,
- padding: 10,
- borderWidth: 1,
- borderColor: '#2a2a2a',
+ flexDirection: 'row',
+ backgroundColor: '#0e0e1c',
+ borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
+ overflow: 'hidden',
},
- itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
- frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
- time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
- coord: { color: '#888', fontSize: 10, marginBottom: 4 },
- itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
- meta: { color: '#555', fontSize: 10 },
- gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
- sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
- empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
- emptyText: { color: '#444', fontSize: 16 },
- emptyHint: { color: '#333', fontSize: 12 },
+ itemLeft: {
+ width: 36, backgroundColor: '#0a0a14',
+ alignItems: 'center', justifyContent: 'center',
+ borderRightWidth: 1, borderRightColor: '#141422',
+ },
+ itemIndex: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ itemBody: { flex: 1, padding: 10, gap: 5 },
+
+ itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
+ frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ time: { color: '#3a3a5a', fontSize: 10, flex: 1, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ gpsBadge: { borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
+ waveHint: { backgroundColor: '#0d1a2e', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
+ waveHintText:{ color: '#2a5a9f', fontSize: 9, fontWeight: '600' },
+ itemDelBtn: { width: 22, height: 22, borderRadius: 11, backgroundColor: '#2a0a0e', borderWidth: 1, borderColor: '#3a1520', alignItems: 'center', justifyContent: 'center' },
+ itemDelTxt: { color: '#ff5c6e', fontSize: 14, lineHeight: 18, fontWeight: '700' },
+
+ coord: { color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+
+ itemFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 },
+ peakRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
+ peakChip: { flexDirection: 'row', alignItems: 'center', gap: 3 },
+ peakDot: { width: 5, height: 5, borderRadius: 3 },
+ peakVal: { color: '#6a6a8a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ metaRow: { flexDirection: 'row', gap: 6 },
+ metaTag: { color: '#2a2a4a', fontSize: 9, backgroundColor: '#111120', borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
+
+ empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
+ emptyIcon: { fontSize: 40, color: '#1a1a2a' },
+ emptyText: { color: '#2a2a4a', fontSize: 15 },
+ emptyHint: { color: '#1a1a2a', fontSize: 12 },
+});
+
+const M = StyleSheet.create({
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
+ sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, paddingBottom: 32 },
+ header: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
+ title: { color: '#9090b8', fontSize: 14, fontWeight: '700', flex: 1 },
+ scaleBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a2a' },
+ scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
+ scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700' },
+ scaleTxtOn: { color: '#4a9eff' },
+ closeBtn: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#1a1a2a', alignItems: 'center', justifyContent: 'center' },
+ closeTxt: { color: '#6a6a8a', fontSize: 14 },
+ noData: { backgroundColor: '#111', borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
+ noDataTxt:{ color: '#444' },
+ peakRow: { flexDirection: 'row', marginTop: 10, gap: 8 },
+ peakCell: { alignItems: 'center', backgroundColor: '#111120', borderRadius: 8, padding: 8, borderWidth: 1, borderColor: '#1e1e30' },
+ dot: { width: 6, height: 6, borderRadius: 3, marginBottom: 3 },
+ peakCh: { color: '#4a4a6a', fontSize: 9 },
+ peakVal: { color: '#9090b8', fontSize: 11, fontWeight: '600', fontFamily: 'monospace' },
+ meta: { marginTop: 10, backgroundColor: '#0a0a12', borderRadius: 8, padding: 10, gap: 3 },
+ metaTxt: { color: '#3a3a5a', fontSize: 10, fontFamily: 'monospace' },
});
diff --git a/app/(tabs)/two.tsx b/app/(tabs)/two.tsx
deleted file mode 100644
index f2ea47e..0000000
--- a/app/(tabs)/two.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { StyleSheet } from 'react-native';
-
-import EditScreenInfo from '@/components/EditScreenInfo';
-import { Text, View } from '@/components/Themed';
-
-export default function TabTwoScreen() {
- return (
-
- Tab Two
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
- title: {
- fontSize: 20,
- fontWeight: 'bold',
- },
- separator: {
- marginVertical: 30,
- height: 1,
- width: '80%',
- },
-});
diff --git a/app/(tabs)/wave.tsx b/app/(tabs)/wave.tsx
new file mode 100644
index 0000000..30b5066
--- /dev/null
+++ b/app/(tabs)/wave.tsx
@@ -0,0 +1,558 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+ View, Text, StyleSheet, TouchableOpacity, ScrollView,
+ Modal, ActivityIndicator, useWindowDimensions, Platform,
+} from 'react-native';
+import { useConnectionStore } from '../../src/stores/connectionStore';
+import { useDeviceStore } from '../../src/stores/deviceStore';
+import { useDataStore } from '../../src/stores/dataStore';
+import { useDevice } from '../../src/hooks/useDevice';
+import { useWaveform } from '../../src/hooks/useWaveform';
+import { WaveformChart } from '../../src/components/WaveformChart';
+import { ParamForm } from '../../src/components/ParamForm';
+import { CHANNEL_COLORS } from '../../src/protocol/constants';
+import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
+import { Colors, Spacing, Radius } from '../../src/design/tokens';
+import type { MeasurementFrame } from '../../src/protocol/types';
+
+// ── Param sheet ───────────────────────────────────────────────────────────────
+
+function ParamSheet({
+ visible,
+ onClose,
+ onSetup,
+ busy,
+ configDirty,
+}: {
+ visible: boolean;
+ onClose: () => void;
+ onSetup: () => Promise;
+ busy: boolean;
+ configDirty: boolean;
+}) {
+ const { deviceStatus } = useDeviceStore();
+ const measuring = deviceStatus !== 'idle';
+
+ return (
+
+
+
+
+
+
+ 采集参数
+ {configDirty && (
+
+ 待下发
+
+ )}
+
+
+
+
+
+
+
+
+ {busy
+ ?
+ :
+ {measuring ? '采集中,不可下发' : '下 发 配 置'}
+ }
+
+
+
+ );
+}
+
+const PS = StyleSheet.create({
+ backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
+ sheet: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ maxHeight: '85%',
+ backgroundColor: Colors.bg.surface,
+ borderTopLeftRadius: Radius.xl,
+ borderTopRightRadius: Radius.xl,
+ borderTopWidth: 1,
+ borderColor: Colors.bg.border,
+ paddingBottom: Spacing.xxl,
+ },
+ handle: {
+ width: 36, height: 4, borderRadius: 2,
+ backgroundColor: Colors.bg.border,
+ alignSelf: 'center',
+ marginTop: Spacing.sm, marginBottom: Spacing.sm,
+ },
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: Spacing.sm },
+ title: { color: Colors.text.secondary, fontSize: 14, fontWeight: '700', flex: 1 },
+ dirtyBadge: { backgroundColor: Colors.amber.bg, borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: Colors.amber.border },
+ dirtyTxt: { color: Colors.amber.fg, fontSize: 9, fontWeight: '700' },
+ scroll: { flex: 1 },
+ setupBtn: {
+ margin: Spacing.lg,
+ backgroundColor: Colors.bg.raised,
+ borderRadius: Radius.lg,
+ paddingVertical: 14,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: Colors.bg.border,
+ },
+ setupBtnDirty: { backgroundColor: Colors.blue.bg, borderColor: Colors.blue.fg },
+ setupBtnDis: { opacity: 0.4 },
+ setupBtnTxt: { color: Colors.text.muted, fontWeight: '700', fontSize: 14, letterSpacing: 3 },
+ setupBtnTxtDirty: { color: Colors.blue.fg },
+});
+
+// ── Control row ───────────────────────────────────────────────────────────────
+
+function ControlRow({
+ deviceStatus,
+ busy,
+ frameId,
+ onContinuous,
+ onSingle,
+ onStop,
+ onSettings,
+ configDirty,
+}: {
+ deviceStatus: 'idle' | 'running' | 'single';
+ busy: boolean;
+ frameId: number | undefined;
+ onContinuous: () => void;
+ onSingle: () => void;
+ onStop: () => void;
+ onSettings: () => void;
+ configDirty: boolean;
+}) {
+ const idle = deviceStatus === 'idle';
+ const running = deviceStatus === 'running';
+ const single = deviceStatus === 'single';
+
+ return (
+
+ {/* Primary action */}
+ {idle && (
+
+ {busy
+ ?
+ : <>
+ ▶
+ 连 续 采 集
+ >}
+
+ )}
+ {running && (
+
+ ■
+ 停 止
+
+ )}
+ {single && (
+
+
+ 单次采集中…
+
+ )}
+
+ {/* Secondary: single-shot button (idle only) */}
+ {idle && (
+
+ ◎
+ 单次
+
+ )}
+
+ {/* Running indicator (running only) */}
+ {running && frameId !== undefined && (
+
+
+ #{frameId}
+
+ )}
+
+ {/* Settings button — always present */}
+
+ ⚙
+ {configDirty && }
+
+
+ );
+}
+
+const CR = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.md,
+ paddingVertical: Spacing.sm,
+ gap: Spacing.sm,
+ borderTopWidth: 1,
+ borderTopColor: Colors.bg.border,
+ backgroundColor: Colors.bg.surface,
+ },
+ primary: {
+ flex: 2,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ paddingVertical: 14,
+ borderRadius: Radius.lg,
+ borderWidth: 1,
+ },
+ green: { backgroundColor: Colors.green.bg, borderColor: Colors.green.border },
+ red: { backgroundColor: Colors.red.bg, borderColor: Colors.red.border },
+ teal: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
+ dis: { opacity: 0.35 },
+ primaryIcon: { fontSize: 11, color: '#ffffff88' },
+ primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
+
+ secondary: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 5,
+ paddingVertical: 14,
+ borderRadius: Radius.lg,
+ borderWidth: 1,
+ },
+ tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
+ secondaryIcon:{ fontSize: 11, color: '#ffffff66' },
+ secondaryTxt: { fontSize: 12, fontWeight: '700' },
+
+ indicator: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ paddingVertical: 14,
+ borderRadius: Radius.lg,
+ backgroundColor: Colors.green.bg,
+ borderWidth: 1,
+ borderColor: Colors.green.border,
+ },
+ runDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.green.fg },
+ indicatorTxt: { color: Colors.green.fg, fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+
+ settingsBtn: {
+ width: 44, height: 44,
+ borderRadius: Radius.md,
+ backgroundColor: Colors.bg.raised,
+ borderWidth: 1, borderColor: Colors.bg.border,
+ alignItems: 'center', justifyContent: 'center',
+ },
+ settingsTxt: { fontSize: 18, color: Colors.text.muted },
+ settingsDirty: { color: Colors.amber.fg },
+ dirtyDot: {
+ position: 'absolute', top: 6, right: 6,
+ width: 7, height: 7, borderRadius: 4,
+ backgroundColor: Colors.amber.fg,
+ borderWidth: 1, borderColor: Colors.bg.surface,
+ },
+});
+
+// ── Peak row ──────────────────────────────────────────────────────────────────
+
+function PeakRow({ frame, visible, channelNum }: { frame: MeasurementFrame; visible: boolean[]; channelNum: number }) {
+ return (
+
+
+ {frame.adcUV.map((ch, i) => {
+ if (i >= channelNum || !visible[i]) return null;
+ const pk = ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
+ return (
+
+
+ CH{i + 1}
+ {formatUV(pk)}
+
+ );
+ })}
+
+
+ );
+}
+
+const PK = StyleSheet.create({
+ scroll: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
+ row: { flexDirection: 'row', paddingHorizontal: Spacing.md, paddingVertical: 7, gap: 10 },
+ cell: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.bg.surface, borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: Colors.bg.border },
+ dot: { width: 5, height: 5, borderRadius: 3 },
+ label: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600' },
+ value: { fontSize: 11, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+});
+
+// ── Frame summary bar ─────────────────────────────────────────────────────────
+
+function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
+ const m = frame.meta;
+ return (
+
+
+ #{frame.frameId}
+ {' '}×{frame.accNum}叠
+ {m ? ` ${formatUtc(m.utc)}` : ''}
+ {m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
+
+
+ );
+}
+
+const FS = StyleSheet.create({
+ bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, backgroundColor: Colors.bg.void, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
+ txt: { color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+});
+
+// ── Not connected placeholder ─────────────────────────────────────────────────
+
+function NotConnected({ onConnect }: { onConnect: () => void }) {
+ return (
+
+ ◈
+ 未连接仪器
+ 请先连接 TEM 接收机
+
+ 连 接 设 备
+
+
+ );
+}
+
+const NC = StyleSheet.create({
+ root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
+ icon: { fontSize: 48, color: Colors.bg.border },
+ title: { color: Colors.text.muted, fontSize: 17, fontWeight: '700' },
+ sub: { color: Colors.text.ghost, fontSize: 12 },
+ btn: {
+ marginTop: Spacing.sm,
+ backgroundColor: Colors.blue.bg,
+ borderRadius: Radius.lg,
+ paddingVertical: 13,
+ paddingHorizontal: 32,
+ borderWidth: 1, borderColor: Colors.blue.fg,
+ },
+ btnTxt: { color: Colors.blue.fg, fontSize: 14, fontWeight: '700', letterSpacing: 2 },
+});
+
+// ── Main screen ───────────────────────────────────────────────────────────────
+
+export default function WaveScreen() {
+ const { width: sw, height: sh } = useWindowDimensions();
+ const { status, showModal } = useConnectionStore();
+ const { config, configDirty } = useDeviceStore();
+ const { deviceStatus } = useDeviceStore();
+ const { busy, setup, startContinuous, startSingle, stop } = useDevice();
+
+ const frame = useDataStore((s) => s.currentFrame);
+ const sessionId = useDataStore((s) => s.sessionId);
+ const projectId = useDataStore((s) => s.projectId);
+
+ const [visible, setVisible] = useState(() => Array(6).fill(true));
+ const [logScale, setLogScale] = useState(true);
+ const [sheetVisible, setSheetVisible] = useState(false);
+ const [projectName, setProjectName] = useState(null);
+
+ const connected = status === 'connected';
+ const channelNum = config.channelNum;
+ const CHART_H = Math.round(Math.min(280, sh * 0.36));
+
+ const data = useWaveform(visible);
+
+ useEffect(() => {
+ if (!projectId) { setProjectName(null); return; }
+ import('../../src/services/StorageService').then(({ listProjects }) =>
+ listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
+ );
+ }, [projectId]);
+
+ const toggleChannel = (idx: number) =>
+ setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
+
+ const handleSetup = async () => {
+ await setup();
+ setSheetVisible(false);
+ };
+
+ if (!connected) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* ── Context bar: project · session ── */}
+
+ {projectName
+ ? {projectName} · {sessionId}
+ : {sessionId}}
+
+
+ {/* ── Channel selector + LOG/LIN ── */}
+
+
+ {Array.from({ length: channelNum }, (_, i) => (
+ toggleChannel(i)}
+ activeOpacity={0.7}
+ >
+
+ CH{i + 1}
+
+ ))}
+
+ setLogScale((v) => !v)}
+ activeOpacity={0.7}
+ >
+ {logScale ? 'LOG' : 'LIN'}
+
+
+
+ {/* ── Waveform chart ── */}
+
+ {data ? (
+
+ ) : (
+
+ 等待采样数据…
+
+ )}
+
+
+ {/* ── Peak values ── */}
+ {frame && }
+
+ {/* ── Frame summary ── */}
+ {frame && }
+
+ {/* ── Control row ── */}
+ setSheetVisible(true)}
+ configDirty={configDirty}
+ />
+
+ {/* ── Param sheet ── */}
+ setSheetVisible(false)}
+ onSetup={handleSetup}
+ busy={busy}
+ configDirty={configDirty}
+ />
+
+ );
+}
+
+const S = StyleSheet.create({
+ root: { flex: 1, backgroundColor: Colors.bg.base },
+
+ ctxBar: {
+ paddingHorizontal: Spacing.md,
+ paddingVertical: 4,
+ backgroundColor: Colors.bg.void,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.bg.divider,
+ },
+ ctxTxt: {
+ color: Colors.text.ghost,
+ fontSize: 9,
+ fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
+ },
+
+ toolbar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingRight: Spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.bg.divider,
+ },
+ chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
+ chBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ paddingHorizontal: 8,
+ paddingVertical: 5,
+ borderRadius: Radius.md,
+ borderWidth: 1,
+ borderColor: Colors.bg.border,
+ },
+ chDot: { width: 6, height: 6, borderRadius: 3 },
+ chTxt: { color: Colors.text.ghost, fontSize: 10, fontWeight: '600' },
+ logBtn: {
+ paddingHorizontal: 9,
+ paddingVertical: 5,
+ borderRadius: Radius.sm,
+ borderWidth: 1,
+ borderColor: Colors.bg.border,
+ backgroundColor: Colors.bg.raised,
+ marginLeft: 4,
+ },
+ logBtnOn: { borderColor: Colors.blue.border, backgroundColor: Colors.blue.bg },
+ logTxt: { color: Colors.text.muted, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
+ logTxtOn: { color: Colors.blue.fg },
+
+ chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.bg.divider },
+ chartPlaceholder: {
+ backgroundColor: Colors.bg.surface,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ placeholderTxt: { color: Colors.text.ghost, fontSize: 13 },
+});
diff --git a/app/(tabs)/waveform.tsx b/app/(tabs)/waveform.tsx
index e253898..2b80a7a 100644
--- a/app/(tabs)/waveform.tsx
+++ b/app/(tabs)/waveform.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
-import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
+import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, ScrollView } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ChannelSelector } from '../../src/components/ChannelSelector';
@@ -9,68 +10,79 @@ import { useDeviceStore } from '../../src/stores/deviceStore';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
export default function WaveformScreen() {
- const { width } = useWindowDimensions();
+ const insets = useSafeAreaInsets();
+ const { width, height: windowHeight } = useWindowDimensions();
const channelNum = useDeviceStore((s) => s.config.channelNum);
const [visible, setVisible] = useState(() => Array(6).fill(true));
+ const [logScale, setLogScale] = useState(true);
const frame = useDataStore((s) => s.currentFrame);
- const data = useWaveform(visible);
+ const data = useWaveform(visible);
- const toggleChannel = (idx: number) => {
+ const toggleChannel = (idx: number) =>
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
- };
- const CHART_HEIGHT = 280;
+ const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38));
return (
-
+
-
+
+
+ setLogScale((v) => !v)}
+ activeOpacity={0.7}
+ >
+
+ {logScale ? 'LOG' : 'LIN'}
+
+
+
{/* Waveform chart */}
-
+
{data ? (
) : (
-
- 等待采样数据...
+
+ 等待采样数据...
)}
- {/* Channel peak values */}
+ {/* Per-channel peak values + metadata */}
{frame && (
-
-
+
+
{frame.adcUV.map((ch, idx) => {
if (!visible[idx] || idx >= channelNum) return null;
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
- const peak = ch[0] < 0 ? -absMax : absMax;
return (
-
- CH{idx + 1}
- {formatUV(absMax)}
+
+ CH{idx + 1}
+ {formatUV(absMax)}
);
})}
- {/* Frame metadata */}
{frame.meta && (
-
-
+
+
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
-
+
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
-
+
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
@@ -81,34 +93,36 @@ export default function WaveformScreen() {
);
}
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#0d0d0d' },
- chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
- placeholder: {
- backgroundColor: '#1a1a1a',
- justifyContent: 'center',
- alignItems: 'center',
- },
+const S = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0d0d0d' },
+ toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
+ chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
+ placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#444', fontSize: 14 },
+
+ scaleBtn: {
+ marginLeft: 8,
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ borderRadius: 6,
+ borderWidth: 1,
+ borderColor: '#333',
+ backgroundColor: '#1a1a1a',
+ },
+ scaleBtnActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
+ scaleBtnText: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
+ scaleBtnTextActive:{ color: '#4a9eff' },
+
peakScroll: { flex: 1 },
- peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
+ peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
peakCell: {
backgroundColor: '#1a1a1a',
- borderRadius: 8,
- padding: 10,
- minWidth: 90,
- alignItems: 'center',
- borderWidth: 1,
- borderColor: '#2a2a2a',
+ borderRadius: 8, padding: 10,
+ minWidth: 90, alignItems: 'center',
+ borderWidth: 1, borderColor: '#2a2a2a',
},
peakLabel: { color: '#888', fontSize: 11 },
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
- metaBlock: {
- margin: 12,
- backgroundColor: '#111',
- borderRadius: 8,
- padding: 10,
- gap: 3,
- },
- metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
+ metaBlock: { margin: 12, backgroundColor: '#111', borderRadius: 8, padding: 10, gap: 3 },
+ metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f17420c..3927f14 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -5,26 +5,29 @@ import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native';
import 'react-native-reanimated';
+import { useDataStore } from '../src/stores/dataStore';
+import { ConnectModal } from '../src/components/modals/ConnectModal';
SplashScreen.preventAutoHideAsync();
export { ErrorBoundary } from 'expo-router';
-export const unstable_settings = { initialRouteName: 'connect' };
-
export default function RootLayout() {
+ const init = useDataStore((s) => s.init);
+
useEffect(() => {
- SplashScreen.hideAsync();
+ init().finally(() => SplashScreen.hideAsync());
}, []);
return (
-
-
+
+ {/* ConnectModal is global — accessible from any tab */}
+
);
}
diff --git a/app/connect.tsx b/app/connect.tsx
index 0139c61..73df294 100644
--- a/app/connect.tsx
+++ b/app/connect.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
@@ -8,7 +8,10 @@ import {
ScrollView,
Linking,
ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { useConnectionStore } from '../src/stores/connectionStore';
import { useDevice } from '../src/hooks/useDevice';
@@ -16,36 +19,35 @@ import { useDevice } from '../src/hooks/useDevice';
const LOG_MAX = 60;
export default function ConnectScreen() {
- const { host, port, status, setHost, setPort } = useConnectionStore();
+ const { host, port, status, lastError, setHost, setPort } = useConnectionStore();
const { connect, disconnect } = useDevice();
- const [logs, setLogs] = useState(['准备连接...']);
+ const [logs, setLogs] = useState(['Ready.']);
const [portStr, setPortStr] = useState(String(port));
+ const prevStatusRef = useRef(status);
const addLog = (msg: string) => {
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
};
+ useEffect(() => {
+ const prev = prevStatusRef.current;
+ prevStatusRef.current = status;
+ if (status === prev) return;
+ if (status === 'connected') {
+ addLog('连接成功 ✓');
+ router.push('/(tabs)/control');
+ } else if (status === 'error') {
+ addLog(`错误: ${lastError}`);
+ } else if (status === 'reconnecting') {
+ addLog('断线,正在重连...');
+ }
+ }, [status, lastError]);
+
const handleConnect = () => {
const p = parseInt(portStr, 10);
- if (!host || isNaN(p)) {
- addLog('请检查 IP 和端口');
- return;
- }
+ if (!host || isNaN(p)) { addLog('请检查 IP 和端口'); return; }
setPort(p);
- addLog(`正在连接 ${host}:${p}...`);
-
- // Watch for status changes via the hook (side effect in useDevice)
- useConnectionStore.subscribe((s) => {
- if (s.status === 'connected') {
- addLog('连接成功!');
- router.replace('/(tabs)/control');
- } else if (s.status === 'error') {
- addLog(`错误: ${s.lastError}`);
- } else if (s.status === 'reconnecting') {
- addLog('断线,正在重连...');
- }
- });
-
+ addLog(`正在连接 ${host}:${p} ...`);
connect();
};
@@ -58,150 +60,178 @@ export default function ConnectScreen() {
const isConnected = status === 'connected';
return (
-
- TEM Receiver
- 瞬变电磁接收机上位机
+
+
+
- {/* Connection steps */}
-
- 连接步骤
- ① 在手机 WiFi 设置中连接设备热点
- Linking.openSettings()}>
- 打开 WiFi 设置 →
-
- ② 确认连接参数后点击连接
-
-
- {/* IP / Port input */}
-
-
- 设备 IP
-
-
-
- 端口
-
-
-
-
- {/* Connect / Disconnect button */}
- {!isConnected ? (
-
- {isConnecting ? (
-
- ) : (
- 连 接 设 备
- )}
-
- ) : (
-
-
-
- 已连接
+ {/* ── Brand ── */}
+
+ ◈
+ TEM Receiver
+ 瞬变电磁接收机上位机
+
+
+ {/* ── Setup guide ── */}
+
+ 连接步骤
+
+ 1
+ 手机连接设备 WiFi 热点
+ Linking.openSettings()}>
+ 设置 →
+
+
+
+ 2
+ 确认参数,点击连接
+
+
+
+ {/* ── Inputs ── */}
+
+ 连接参数
+
+ IP 地址
+
+
+
+
+ 端 口
+
+
+
+
+ {/* ── Action ── */}
+ {!isConnected ? (
+
+ {isConnecting
+ ?
+ : 连 接 设 备}
+
+ ) : (
+
+
+
+ 已连接
+
+ router.push('/(tabs)/control')} activeOpacity={0.8}>
+ 进入控制台 →
+
+
+ 断开
+
+
+ )}
+
+ {/* ── Log terminal ── */}
+
+ ● 连接日志
+
+ {logs.map((l, i) => (
+ {l}
+ ))}
+
- router.replace('/(tabs)/control')}>
- 进入控制台 →
-
-
- 断开
-
-
- )}
- {/* Connection log */}
-
- 连接日志
-
- {logs.map((l, i) => (
- {l}
- ))}
-
-
+
+
);
}
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
- title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
- subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
+const S = StyleSheet.create({
+ safe: { flex: 1, backgroundColor: '#090912' },
+ flex: { flex: 1 },
+ scroll: { flex: 1 },
+ scrollContent: { padding: 20, paddingBottom: 40 },
+
+ // Brand
+ brand: { alignItems: 'center', marginBottom: 28, marginTop: 8 },
+ brandIcon: { fontSize: 36, color: '#4a9eff', marginBottom: 8 },
+ title: { color: '#e4e4f0', fontSize: 26, fontWeight: '700', letterSpacing: 1 },
+ subtitle: { color: '#4a4a6a', fontSize: 12, marginTop: 4, letterSpacing: 0.5 },
+
+ // Card
card: {
- backgroundColor: '#1a1a1a',
- borderRadius: 10,
- padding: 14,
- marginBottom: 12,
+ backgroundColor: '#111120',
+ borderRadius: 14,
borderWidth: 1,
- borderColor: '#2a2a2a',
+ borderColor: '#22223a',
+ padding: 16,
+ marginBottom: 12,
},
- cardTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600', marginBottom: 8 },
- step: { color: '#bbb', fontSize: 13, marginBottom: 6 },
- wifiBtn: {
- backgroundColor: '#0d2b55',
- borderRadius: 6,
- paddingVertical: 6,
- paddingHorizontal: 12,
- alignSelf: 'flex-start',
- marginBottom: 10,
- },
- wifiBtnText: { color: '#4a9eff', fontSize: 12 },
- inputRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
- inputLabel: { color: '#888', fontSize: 13, width: 50 },
+ cardLabel: { color: '#4a4a6a', fontSize: 10, fontWeight: '600', letterSpacing: 1.5, marginBottom: 12, textTransform: 'uppercase' },
+
+ // Steps
+ stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
+ stepBadge: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#1a2a44', justifyContent: 'center', alignItems: 'center' },
+ stepNum: { color: '#4a9eff', fontSize: 11, fontWeight: '700' },
+ stepText: { color: '#8888aa', fontSize: 13, flex: 1 },
+ linkBtn: { backgroundColor: '#0d2040', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#1a3a66' },
+ linkBtnText: { color: '#4a9eff', fontSize: 11, fontWeight: '600' },
+
+ // Inputs
+ inputRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
+ inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 10 },
+ inputLabel: { color: '#5a5a7a', fontSize: 12, width: 54, letterSpacing: 0.3 },
input: {
flex: 1,
- backgroundColor: '#111',
- color: '#eee',
- borderRadius: 6,
- paddingHorizontal: 10,
+ color: '#d0d0e8',
+ fontSize: 15,
+ fontWeight: '500',
paddingVertical: 6,
- fontSize: 14,
- borderWidth: 1,
- borderColor: '#333',
+ borderBottomWidth: 1,
+ borderBottomColor: '#2a2a48',
},
+
+ // Connect button
connectBtn: {
- backgroundColor: '#4a9eff',
- borderRadius: 10,
- paddingVertical: 14,
+ backgroundColor: '#1a3a6e',
+ borderRadius: 14,
+ paddingVertical: 16,
alignItems: 'center',
marginBottom: 12,
- },
- connectBtnBusy: { backgroundColor: '#2a5a99' },
- connectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: 2 },
- connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
- connectedBadge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
- greenDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#44cc44' },
- connectedText: { color: '#44cc44', fontWeight: '600' },
- goBtn: { flex: 1, backgroundColor: '#1a4a22', borderRadius: 8, padding: 10, alignItems: 'center' },
- goBtnText: { color: '#44cc44', fontWeight: '600' },
- disconnectBtn: { backgroundColor: '#3a1010', borderRadius: 8, padding: 10 },
- disconnectBtnText: { color: '#ff6666' },
- logCard: {
- flex: 1,
- backgroundColor: '#111',
- borderRadius: 10,
- padding: 10,
borderWidth: 1,
- borderColor: '#222',
+ borderColor: '#4a9eff',
+ marginTop: 4,
},
- logTitle: { color: '#666', fontSize: 11, marginBottom: 4 },
- logScroll: { flex: 1 },
- logLine: { color: '#555', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 },
+ connectBtnBusy: { borderColor: '#2a4a7a', backgroundColor: '#0f1f3a' },
+ connectBtnText: { color: '#4a9eff', fontSize: 16, fontWeight: '700', letterSpacing: 3 },
+
+ // Connected state
+ connectedBox: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12, marginTop: 4 },
+ connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#3ddc84' },
+ connectedText: { color: '#3ddc84', fontWeight: '700', fontSize: 13 },
+ goBtn: { flex: 1, backgroundColor: '#0d2820', borderRadius: 10, paddingVertical: 11, alignItems: 'center', borderWidth: 1, borderColor: '#1a4a30' },
+ goBtnText: { color: '#3ddc84', fontWeight: '600', fontSize: 13 },
+ disconnectBtn: { backgroundColor: '#2a0d12', borderRadius: 10, paddingVertical: 11, paddingHorizontal: 14, borderWidth: 1, borderColor: '#4a1a22' },
+ disconnectBtnText: { color: '#ff5c6e', fontSize: 13, fontWeight: '600' },
+
+ // Terminal log
+ terminal: { backgroundColor: '#08080f', borderRadius: 12, borderWidth: 1, borderColor: '#1a1a28', overflow: 'hidden', minHeight: 120 },
+ terminalHeader: { color: '#3ddc84', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, borderBottomWidth: 1, borderBottomColor: '#141422' },
+ terminalBody: { padding: 12 },
+ terminalLine: { color: '#3a3a5a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 3, lineHeight: 16 },
+ terminalLineLatest: { color: '#6a6a9a' },
});
diff --git a/package-lock.json b/package-lock.json
index bae56e8..ad7ff86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"expo": "~56.0.9",
"expo-constants": "~56.0.17",
"expo-dev-client": "~56.0.19",
+ "expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
"expo-linking": "~56.0.13",
@@ -23,6 +24,7 @@
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-web-browser": "~56.0.5",
+ "fflate": "^0.8.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
@@ -3960,6 +3962,15 @@
"expo": "*"
}
},
+ "node_modules/expo-document-picker": {
+ "version": "56.0.4",
+ "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-56.0.4.tgz",
+ "integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-file-system": {
"version": "56.0.7",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz",
@@ -4562,6 +4573,12 @@
"integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==",
"license": "MIT"
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
diff --git a/package.json b/package.json
index 5a30fbc..abd5cc6 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"expo": "~56.0.9",
"expo-constants": "~56.0.17",
"expo-dev-client": "~56.0.19",
+ "expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
"expo-linking": "~56.0.13",
@@ -18,6 +19,7 @@
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-web-browser": "~56.0.5",
+ "fflate": "^0.8.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
diff --git a/run-android.bat b/run-android.bat
new file mode 100644
index 0000000..a862b6d
--- /dev/null
+++ b/run-android.bat
@@ -0,0 +1,23 @@
+@echo off
+chcp 65001 >nul
+title TEM Receiver - Android Build
+
+echo ================================
+echo TEM Receiver - Run on Android
+echo ================================
+
+set "JAVA_HOME=D:\Program Files\Android\Android Studio\jbr"
+set "ANDROID_HOME=D:\ProgramData\AndroidSdk"
+set "PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\platform-tools;%PATH%"
+
+echo [1/3] Checking ADB device...
+adb devices
+echo.
+
+echo [2/3] Starting Metro + building APK...
+cd /d "%~dp0"
+npx expo run:android
+
+echo.
+echo Done.
+pause
diff --git a/run-android.ps1 b/run-android.ps1
new file mode 100644
index 0000000..33fea01
--- /dev/null
+++ b/run-android.ps1
@@ -0,0 +1,26 @@
+$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr"
+$env:ANDROID_HOME = "D:\ProgramData\AndroidSdk"
+$env:PATH = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:PATH"
+
+# Force Gradle to flush output line-by-line (no buffering)
+$env:GRADLE_OPTS = "-Dorg.gradle.console=plain -Dorg.gradle.logging.level=lifecycle"
+# Force Node/Metro color output
+$env:FORCE_COLOR = "1"
+
+Write-Host "================================" -ForegroundColor Cyan
+Write-Host " TEM Receiver - Run on Android" -ForegroundColor Cyan
+Write-Host "================================`n" -ForegroundColor Cyan
+
+Write-Host "[1/2] ADB devices:" -ForegroundColor Yellow
+adb devices
+Write-Host ""
+
+Write-Host "[2/2] Building & launching (Metro will start after install)..." -ForegroundColor Yellow
+Write-Host "--------------------------------------------------------------`n" -ForegroundColor DarkGray
+
+Set-Location $PSScriptRoot
+
+# & ensures stdout/stderr stream to console in real time
+& npx expo run:android
+
+Write-Host "`nExited." -ForegroundColor DarkGray
diff --git a/src/components/ChannelSelector.tsx b/src/components/ChannelSelector.tsx
index 978045f..befae0d 100644
--- a/src/components/ChannelSelector.tsx
+++ b/src/components/ChannelSelector.tsx
@@ -10,35 +10,42 @@ interface Props {
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
return (
-
- {Array.from({ length: maxChannels }, (_, i) => (
- onToggle(i)}
- activeOpacity={0.7}
- >
-
-
- CH{i + 1}
-
-
- ))}
+
+ {Array.from({ length: maxChannels }, (_, i) => {
+ const color = CHANNEL_COLORS[i];
+ const active = visible[i];
+ return (
+ onToggle(i)}
+ activeOpacity={0.7}
+ >
+
+ CH{i + 1}
+
+ );
+ })}
);
}
-const styles = StyleSheet.create({
- row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
+const S = StyleSheet.create({
+ row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingVertical: 10 },
chip: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 5,
borderWidth: 1,
- borderRadius: 12,
- paddingHorizontal: 8,
- paddingVertical: 3,
- gap: 4,
+ borderRadius: 20,
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ backgroundColor: '#0c0c18',
},
- dot: { width: 6, height: 6, borderRadius: 3 },
- label: { fontSize: 11, fontWeight: '600' },
+ dot: { width: 6, height: 6, borderRadius: 3 },
+ label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
});
diff --git a/src/components/DeviceStatusBar.tsx b/src/components/DeviceStatusBar.tsx
index 9baecba..ef642fc 100644
--- a/src/components/DeviceStatusBar.tsx
+++ b/src/components/DeviceStatusBar.tsx
@@ -1,15 +1,15 @@
import React from 'react';
-import { View, Text, StyleSheet } from 'react-native';
+import { View, Text, StyleSheet, Platform } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
-import { formatBattery, formatTemperature, formatCoord } from '../utils/format';
+import { formatBattery, formatTemperature } from '../utils/format';
-function Indicator({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
+function Chip({ label, value, color }: { label: string; value: string; color?: string }) {
return (
-
-
- {label}
- {value}
+
+ {color && }
+ {label}
+ {value}
);
}
@@ -19,55 +19,68 @@ export function DeviceStatusBar() {
const frame = useDataStore((s) => s.currentFrame);
const meta = frame?.meta;
+ const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
const lat = meta?.latitude ?? 0;
const lon = meta?.longitude ?? 0;
- const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
- const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
+ const coordStr = hasGps && lat ? `${lat.toFixed(4)}, ${lon.toFixed(4)}` : '--';
const running = deviceStatus === 'running';
- const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
- const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
+ const single = deviceStatus === 'single';
+ const statusColor = running ? '#3ddc84' : single ? '#ffb84d' : '#3a3a5a';
+ const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止';
return (
-
-
-
- {statusLabel}
+
+
+ {statusLabel}
-
- 0 ? 'OK' : '--'} ok={sdStatus > 0} />
-
-
+
+ 0 ? 'OK' : '--'} color={sdStatus > 0 ? '#4a9eff' : '#3a3a5a'} />
+ {!!batteryVolt && }
+ {!!temperature && }
+ {frame && (
+
+ #{frame.frameId}
+
+ )}
);
}
-const styles = StyleSheet.create({
+const S = StyleSheet.create({
bar: {
flexDirection: 'row',
- backgroundColor: '#111',
+ backgroundColor: '#0c0c18',
paddingHorizontal: 12,
- paddingVertical: 6,
+ paddingVertical: 8,
alignItems: 'center',
flexWrap: 'wrap',
- gap: 10,
+ gap: 6,
borderBottomWidth: 1,
- borderBottomColor: '#333',
+ borderBottomColor: '#1a1a2a',
},
- statusBadge: {
+ statusPill: {
+ borderRadius: 6,
+ borderWidth: 1,
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ },
+ statusText: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
+ chip: {
flexDirection: 'row',
alignItems: 'center',
- borderWidth: 1,
- borderRadius: 8,
- paddingHorizontal: 6,
- paddingVertical: 2,
gap: 4,
+ backgroundColor: '#111120',
+ borderRadius: 6,
+ borderWidth: 1,
+ borderColor: '#22223a',
+ paddingHorizontal: 7,
+ paddingVertical: 3,
},
- statusDot: { width: 6, height: 6, borderRadius: 3 },
- statusText: { fontSize: 11, fontWeight: '600' },
- indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
- dot: { width: 6, height: 6, borderRadius: 3 },
- label: { color: '#888', fontSize: 10 },
- value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
+ chipDot: { width: 5, height: 5, borderRadius: 3 },
+ chipLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
+ chipValue: { color: '#6a6a8a', fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ frameCount: { marginLeft: 'auto' },
+ frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
diff --git a/src/components/ParamForm.tsx b/src/components/ParamForm.tsx
index 5783d49..ed05457 100644
--- a/src/components/ParamForm.tsx
+++ b/src/components/ParamForm.tsx
@@ -1,12 +1,7 @@
-import React from 'react';
-import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
+import React, { useState, useEffect } from 'react';
+import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch, Platform } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
-import {
- SEND_FREQ_TABLE,
- SAMPLE_FREQ_TABLE,
- AMP_RATIO_TABLE,
- SOURCE_MODE_TABLE,
-} from '../protocol/constants';
+import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
interface PickerRowProps {
label: string;
@@ -17,20 +12,22 @@ interface PickerRowProps {
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
return (
-
- {label}
-
- {options.map((o) => (
- onChange(o.code)}
- >
-
- {o.label}
-
-
- ))}
+
+ {label}
+
+ {options.map((o) => {
+ const active = o.code === value;
+ return (
+ onChange(o.code)}
+ activeOpacity={0.7}
+ >
+ {o.label}
+
+ );
+ })}
);
@@ -39,34 +36,63 @@ function PickerRow({ label, options, value, onChange }: PickerRowProps) {
interface NumberRowProps {
label: string;
value: number;
- onChangeText: (v: string) => void;
+ min: number;
+ max: number;
+ onCommit: (v: number) => void;
suffix?: string;
- keyboardType?: 'numeric';
}
-function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
+// Keeps a local draft string while typing; commits a clamped integer on blur.
+function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) {
+ const [draft, setDraft] = useState(String(value));
+
+ // Sync when the store value changes externally (e.g. presets).
+ useEffect(() => { setDraft(String(value)); }, [value]);
+
+ const commit = () => {
+ const n = parseInt(draft, 10);
+ const clamped = isNaN(n) ? min : Math.max(min, Math.min(max, n));
+ setDraft(String(clamped));
+ if (clamped !== value) onCommit(clamped);
+ };
+
+ const invalid = (() => {
+ const n = parseInt(draft, 10);
+ return isNaN(n) || n < min || n > max;
+ })();
+
return (
-
- {label}
-
+
+ {label}
+
- {suffix && {suffix}}
+ {suffix && {suffix}}
+ {invalid && (
+ {min}–{max}
+ )}
);
}
-interface FormProps {
- onAnyChange?: () => void;
+function SectionHeader({ title }: { title: string }) {
+ return (
+
+ {title}
+
+ );
}
+interface FormProps { onAnyChange?: () => void }
+
export function ParamForm({ onAnyChange }: FormProps = {}) {
const { config, updateConfig } = useDeviceStore();
const patch = (partial: Partial) => {
@@ -75,161 +101,166 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
};
return (
-
- {/* Channel selector */}
-
- 通道数
-
- {[1, 2, 3, 4, 5, 6].map((n) => (
- patch({ channelNum: n })}
- >
-
- {n}
-
-
- ))}
+
+
+
+
+ {/* Channel count */}
+
+ 通道数
+
+ {[1, 2, 3, 4, 5, 6].map((n) => {
+ const active = config.channelNum === n;
+ return (
+ patch({ channelNum: n })}
+ activeOpacity={0.7}
+ >
+ {n}
+
+ );
+ })}
- patch({ sendFreq: v })}
- />
-
- patch({ sampleFreq: v })}
- />
+ patch({ sendFreq: v })} />
+ patch({ sampleFreq: v })} />
patch({ sampleDepth: parseInt(v, 10) || 0 })}
+ label="采样点数" value={config.sampleDepth}
+ min={64} max={65535}
+ onCommit={(v) => { patch({ sampleDepth: v }); }}
/>
-
patch({ accNum: parseInt(v, 10) || 0 })}
+ label="叠加次数" value={config.accNum}
+ min={1} max={9999}
+ onCommit={(v) => { patch({ accNum: v }); }}
/>
- patch({ ampRatio: v })}
- />
+
- patch({ sourceMode: v })}
- />
+ patch({ ampRatio: v })} />
+ patch({ sourceMode: v })} />
- {/* Reverse accumulation flags */}
-
- 反向叠加
-
- CH1-3
+
+
+ {/* Reverse accumulation */}
+
+ 反向叠加
+
+ CH1-3
patch({ negAcc123: v })}
- thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
- trackColor={{ false: '#333', true: '#1a4a88' }}
+ thumbColor={config.negAcc123 ? '#4a9eff' : '#444'}
+ trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
/>
- CH4-6
+ CH4-6
patch({ negAcc456: v })}
- thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
- trackColor={{ false: '#333', true: '#1a4a88' }}
+ thumbColor={config.negAcc456 ? '#4a9eff' : '#444'}
+ trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
/>
patch({ compDisableDelay: parseInt(v, 10) || 0 })}
+ label="补偿延时" value={config.compDisableDelay}
+ min={0} max={65535}
+ onCommit={(v) => { patch({ compDisableDelay: v }); }}
suffix="×50μs"
/>
{/* File prefix */}
-
- 文件前缀
-
+
+ 文件前缀
+
patch({ filePrefix: v.slice(0, 15) })}
maxLength={15}
autoCapitalize="none"
- placeholderTextColor="#555"
+ placeholderTextColor="#3a3a5a"
/>
+
);
}
-const styles = StyleSheet.create({
- container: { backgroundColor: '#111' },
+const S = StyleSheet.create({
+ container: { backgroundColor: '#090912' },
+
+ sectionHeader: {
+ paddingHorizontal: 16,
+ paddingTop: 14,
+ paddingBottom: 6,
+ },
+ sectionTitle: {
+ color: '#3a3a5a',
+ fontSize: 9,
+ fontWeight: '700',
+ letterSpacing: 2,
+ textTransform: 'uppercase',
+ },
+
row: {
flexDirection: 'row',
alignItems: 'center',
- paddingHorizontal: 14,
- paddingVertical: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#2a2a2a',
- gap: 10,
+ borderBottomColor: '#141422',
+ gap: 12,
+ backgroundColor: '#0e0e1c',
+ marginHorizontal: 0,
},
- rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
- optScroll: { flex: 1 },
- optChip: {
- borderWidth: 1,
- borderColor: '#444',
- borderRadius: 8,
- paddingHorizontal: 8,
- paddingVertical: 3,
- marginRight: 6,
- },
- optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
- optText: { color: '#888', fontSize: 11 },
- optTextActive: { color: '#4a9eff', fontWeight: '600' },
- inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
+ rowLabel: { color: '#4a4a6a', fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 },
+
+ // Picker chips
+ optScroll: { flex: 1 },
+ optChip: { borderWidth: 1, borderColor: '#1e1e30', borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
+ optChipActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
+ optText: { color: '#3a3a5a', fontSize: 11 },
+ optTextActive: { color: '#4a9eff', fontWeight: '700' },
+
+ // Number input
+ inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
input: {
flex: 1,
- backgroundColor: '#1e1e1e',
- color: '#eee',
+ color: '#c0c0d8',
fontSize: 13,
- borderRadius: 6,
- paddingHorizontal: 8,
+ fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
paddingVertical: 4,
- borderWidth: 1,
- borderColor: '#333',
+ borderBottomWidth: 1,
+ borderBottomColor: '#1e1e30',
},
- suffix: { color: '#666', fontSize: 11, marginLeft: 6 },
+ inputInvalid: { borderBottomColor: '#ff5c6e' },
+ suffix: { color: '#3a3a5a', fontSize: 10, marginLeft: 8 },
+ hint: { color: '#ff5c6e', fontSize: 9, marginLeft: 4 },
+
+ // Channel chips
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
chChip: {
- width: 28,
- height: 28,
- borderRadius: 14,
+ width: 30,
+ height: 30,
+ borderRadius: 15,
borderWidth: 1,
- borderColor: '#444',
+ borderColor: '#1e1e30',
alignItems: 'center',
justifyContent: 'center',
+ backgroundColor: '#0c0c18',
},
- chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
- chText: { color: '#888', fontSize: 12, fontWeight: '600' },
- chTextActive: { color: '#4a9eff' },
- switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
- switchLabel: { color: '#888', fontSize: 12 },
+ chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' },
+ chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' },
+ chTextActive: { color: '#4a9eff' },
+
+ // Switch row
+ switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
+ switchLabel: { color: '#4a4a6a', fontSize: 12 },
});
diff --git a/src/components/WaveformChart.tsx b/src/components/WaveformChart.tsx
index 68bd73f..9ab35f5 100644
--- a/src/components/WaveformChart.tsx
+++ b/src/components/WaveformChart.tsx
@@ -5,149 +5,221 @@ import { CHANNEL_COLORS } from '../protocol/constants';
import type { WaveformData } from '../hooks/useWaveform';
interface Props {
- data: WaveformData;
+ data: WaveformData;
visibleChannels: boolean[];
- width: number;
- height: number;
+ width: number;
+ height: number;
+ logScale?: boolean; // default true
}
-const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
-const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
+const PADDING = { top: 14, right: 14, bottom: 34, left: 56 };
+const LOG_Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
+const X_TICK_COUNT = 5;
-export function WaveformChart({ data, visibleChannels, width, height }: Props) {
- const plotW = width - PADDING.left - PADDING.right;
- const plotH = height - PADDING.top - PADDING.bottom;
+// Pick a "nice" step for axis ticks.
+function niceStep(range: number, targetCount: number): number {
+ const rough = range / targetCount;
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
+ for (const f of [1, 2, 2.5, 5, 10]) {
+ if (f * mag >= rough) return f * mag;
+ }
+ return 10 * mag;
+}
- const yMin = data.minLogUV - 0.5;
- const yMax = data.maxLogUV + 0.5;
- const yRange = yMax - yMin;
+function xTicks(totalMs: number): number[] {
+ if (totalMs <= 0) return [];
+ const step = niceStep(totalMs, X_TICK_COUNT);
+ const ticks: number[] = [];
+ for (let t = step; t < totalMs * 0.99; t += step) ticks.push(t);
+ return ticks;
+}
- // Map log10(absVal) → canvas y (top=high, bottom=low)
- const toY = (uv: number): number => {
- const absV = Math.abs(uv);
- if (absV < 1e-12) return PADDING.top + plotH;
- const logV = Math.log10(absV);
- return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
- };
+function linYTicks(minUV: number, maxUV: number): number[] {
+ const range = maxUV - minUV;
+ const step = niceStep(range, 6);
+ const start = Math.ceil(minUV / step) * step;
+ const ticks: number[] = [];
+ for (let v = start; v <= maxUV * 1.001; v += step) ticks.push(v);
+ return ticks;
+}
- // Map sample index → canvas x
+function formatUVShort(uv: number): string {
+ const abs = Math.abs(uv);
+ if (abs === 0) return '0';
+ if (abs >= 1e6) return `${(uv / 1e6).toFixed(1)}V`;
+ if (abs >= 1e3) return `${(uv / 1e3).toFixed(1)}mV`;
+ if (abs >= 1) return `${uv.toFixed(0)}μV`;
+ if (abs >= 1e-3) return `${(uv * 1e3).toFixed(1)}nV`;
+ return uv.toExponential(1);
+}
+
+function formatLogTick(v: number): string {
+ if (v >= 1000) return `${v / 1000}k`;
+ return `${v}`;
+}
+
+function formatMsShort(ms: number): string {
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms >= 1) return `${ms % 1 === 0 ? ms : ms.toFixed(1)}ms`;
+ return `${(ms * 1000).toFixed(0)}μs`;
+}
+
+export function WaveformChart({ data, visibleChannels, width, height, logScale = true }: Props) {
+ const plotW = width - PADDING.left - PADDING.right;
+ const plotH = height - PADDING.top - PADDING.bottom;
+
+ // ── Y mapping ────────────────────────────────────────────────────────────
+ const yLogMin = data.minLogUV - 0.5;
+ const yLogMax = data.maxLogUV + 0.5;
+ const yLogRange = yLogMax - yLogMin;
+
+ const yLinMin = data.minUV;
+ const yLinMax = data.maxUV;
+ const yLinRange = Math.max(yLinMax - yLinMin, 1e-9);
+
+ const toY = useMemo(() => {
+ if (logScale) {
+ return (uv: number): number => {
+ const absV = Math.abs(uv);
+ if (absV < 1e-12) return PADDING.top + plotH;
+ const logV = Math.log10(absV);
+ return PADDING.top + plotH - ((logV - yLogMin) / yLogRange) * plotH;
+ };
+ }
+ return (uv: number): number =>
+ PADDING.top + plotH - ((uv - yLinMin) / yLinRange) * plotH;
+ }, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]);
+
+ // ── X mapping ────────────────────────────────────────────────────────────
const toX = (i: number, total: number): number =>
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
- // Y-axis grid lines and labels
- const yGridLines = useMemo(() => {
- return Y_TICKS.filter((v) => {
- const log = Math.log10(v);
- return log >= yMin && log <= yMax;
- });
- }, [yMin, yMax]);
+ const toXms = (ms: number): number =>
+ PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW;
- // Channel paths
+ // ── Grid / tick values ──────────────────────────────────────────────────
+ const yGridValues = useMemo(() => {
+ if (logScale) {
+ return LOG_Y_TICKS.filter(v => {
+ const log = Math.log10(v);
+ return log >= yLogMin && log <= yLogMax;
+ });
+ }
+ return linYTicks(yLinMin, yLinMax);
+ }, [logScale, yLogMin, yLogMax, yLinMin, yLinMax]);
+
+ const xTickValues = useMemo(() => xTicks(data.totalTimeMs), [data.totalTimeMs]);
+
+ // ── Channel paths ────────────────────────────────────────────────────────
const channelPaths = useMemo(() => {
return data.channels.map((ch, idx) => {
if (!visibleChannels[idx]) return null;
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < ch.length; i++) {
+ if (logScale && Math.abs(ch[i]) < 1e-12) continue;
const x = toX(i, ch.length);
const y = toY(ch[i]);
- if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
- if (!moved) {
- path.moveTo(x, y);
- moved = true;
- } else {
- path.lineTo(x, y);
- }
+ if (!isFinite(y)) continue;
+ if (!moved) { path.moveTo(x, y); moved = true; }
+ else path.lineTo(x, y);
}
return path;
});
- }, [data, visibleChannels, plotW, plotH, yMin, yRange]);
+ }, [data, visibleChannels, toY, plotW, plotH]);
+
+ const top = PADDING.top;
+ const bot = PADDING.top + plotH;
+ const left = PADDING.left;
+ const right = PADDING.left + plotW;
return (
-
+
- {/* Y-axis labels (React Native text overlay, positioned absolutely) */}
- {yGridLines.map((v) => {
+ {/* Y-axis labels */}
+ {yGridValues.map((v) => {
const y = toY(v);
- const label = v >= 1000 ? `${v / 1000}k` : v >= 1 ? `${v}` : `${v}`;
+ if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
+ const label = logScale ? formatLogTick(v) : formatUVShort(v);
return (
-
+
{label}
);
})}
- {/* X-axis label */}
-
- 时间 →
+ {/* X-axis tick labels */}
+ {xTickValues.map((ms) => {
+ const x = toXms(ms);
+ return (
+
+ {formatMsShort(ms)}
+
+ );
+ })}
+
+ {/* Axis unit labels */}
+ μV
+
+ {data.totalTimeMs >= 1000 ? 's' : 'ms'}
- {/* Y-axis unit */}
- μV
+ {/* Scale mode badge */}
+
+ {logScale ? 'LOG' : 'LIN'}
+
);
}
-const styles = StyleSheet.create({
- container: { backgroundColor: '#1a1a1a', position: 'relative' },
- axisLabel: {
- position: 'absolute',
- color: '#aaaaaa',
- fontSize: 9,
- textAlign: 'right',
- },
- xLabel: {
- position: 'absolute',
- color: '#aaaaaa',
- fontSize: 9,
- },
- yUnit: {
- position: 'absolute',
- color: '#888888',
- fontSize: 9,
- },
+const S = StyleSheet.create({
+ container: { backgroundColor: '#111111', position: 'relative' },
+ yLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'right' },
+ xLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'center' },
+ unitLabel: { position: 'absolute', color: '#555', fontSize: 9 },
+ scaleBadge: { position: 'absolute', color: '#444', fontSize: 8, fontWeight: '700', letterSpacing: 0.5 },
});
diff --git a/src/components/device/GlobalStatusBar.tsx b/src/components/device/GlobalStatusBar.tsx
new file mode 100644
index 0000000..2c22450
--- /dev/null
+++ b/src/components/device/GlobalStatusBar.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
+import { useConnectionStore } from '../../stores/connectionStore';
+import { useDeviceStore } from '../../stores/deviceStore';
+import { useDataStore } from '../../stores/dataStore';
+import { formatBattery, formatTemperature } from '../../utils/format';
+import { Colors } from '../../design/tokens';
+
+function Pill({
+ label,
+ value,
+ color,
+ onPress,
+}: {
+ label: string;
+ value: string;
+ color: string;
+ onPress?: () => void;
+}) {
+ const inner = (
+
+
+ {label}
+ {value}
+
+ );
+ if (onPress) return {inner};
+ return inner;
+}
+
+export function GlobalStatusBar() {
+ const { status, showModal } = useConnectionStore();
+ const { deviceStatus, batteryVolt, temperature, gpsStatus, sdStatus } = useDeviceStore();
+ const frame = useDataStore((s) => s.currentFrame);
+ const meta = frame?.meta;
+
+ const connected = status === 'connected';
+ const connColor = connected ? Colors.green.fg : status === 'connecting' ? Colors.amber.fg : Colors.text.ghost;
+ const connLabel = connected ? '已连接' : status === 'connecting' ? '连接中' : status === 'reconnecting' ? '重连' : '未连接';
+
+ const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
+ const hasSd = sdStatus > 0;
+
+ const running = deviceStatus === 'running';
+ const single = deviceStatus === 'single';
+ const stateColor = running ? Colors.green.fg : single ? Colors.teal.fg : Colors.text.ghost;
+ const stateLabel = running ? '● 运行' : single ? '◎ 单次' : '○ 停止';
+
+ return (
+
+ {/* Connection — tappable */}
+
+
+ {/* Device run state */}
+ {connected && (
+
+ {stateLabel}
+
+ )}
+
+ {/* GPS */}
+
+
+ {/* SD */}
+
+
+ {/* Battery */}
+ {batteryVolt > 0 && (
+
+ )}
+
+ {/* Temperature */}
+ {temperature > 0 && (
+
+ )}
+
+ {/* Frame counter — right-aligned */}
+ {frame && (
+
+ #{frame.frameId}
+
+ )}
+
+ );
+}
+
+const S = StyleSheet.create({
+ bar: {
+ flexDirection: 'row',
+ backgroundColor: Colors.bg.void,
+ paddingHorizontal: 10,
+ paddingVertical: 7,
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: 5,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.bg.border,
+ },
+ pill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ backgroundColor: Colors.bg.raised,
+ borderRadius: 5,
+ borderWidth: 1,
+ borderColor: Colors.bg.border,
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ },
+ dot: { width: 5, height: 5, borderRadius: 3 },
+ pillLabel: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
+ pillValue: { fontSize: 9, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
+ stateText: { fontSize: 10, fontWeight: '700', letterSpacing: 0.3 },
+ frameCount: { marginLeft: 'auto' as any },
+ frameCountText: {
+ color: Colors.text.ghost,
+ fontSize: 10,
+ fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
+ },
+});
diff --git a/src/components/modals/ConnectModal.tsx b/src/components/modals/ConnectModal.tsx
new file mode 100644
index 0000000..fbabeef
--- /dev/null
+++ b/src/components/modals/ConnectModal.tsx
@@ -0,0 +1,277 @@
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ View, Text, TextInput, TouchableOpacity, StyleSheet,
+ Modal, ScrollView, ActivityIndicator, KeyboardAvoidingView,
+ Platform, Linking,
+} from 'react-native';
+import { useConnectionStore } from '../../stores/connectionStore';
+import { useDevice } from '../../hooks/useDevice';
+import { Colors, Radius, Spacing } from '../../design/tokens';
+
+const LOG_MAX = 20;
+
+export function ConnectModal() {
+ const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
+ useConnectionStore();
+ const { connect, disconnect } = useDevice();
+
+ const [portStr, setPortStr] = useState(String(port));
+ const [logs, setLogs] = useState(['就绪']);
+ const prevStatus = useRef(status);
+
+ const addLog = (msg: string) =>
+ setLogs((p) => [`${new Date().toLocaleTimeString('zh-CN', { hour12: false })} ${msg}`, ...p].slice(0, LOG_MAX));
+
+ // Sync portStr when store port changes (e.g. from persisted storage)
+ useEffect(() => { setPortStr(String(port)); }, [port]);
+
+ useEffect(() => {
+ if (status === prevStatus.current) return;
+ prevStatus.current = status;
+ if (status === 'connected') {
+ addLog('连接成功 ✓');
+ const t = setTimeout(() => hideModal(), 900);
+ return () => clearTimeout(t);
+ }
+ if (status === 'error') addLog(`错误: ${lastError}`);
+ if (status === 'reconnecting') addLog('断线,重连中…');
+ if (status === 'disconnected') addLog('已断开');
+ }, [status, lastError]);
+
+ const handleConnect = () => {
+ const p = parseInt(portStr, 10);
+ if (!host.trim() || isNaN(p)) { addLog('请检查 IP 和端口'); return; }
+ setPort(p);
+ addLog(`连接 ${host}:${p} …`);
+ connect();
+ };
+
+ const handleDisconnect = () => {
+ disconnect();
+ addLog('主动断开');
+ };
+
+ const connecting = status === 'connecting' || status === 'reconnecting';
+ const connected = status === 'connected';
+
+ return (
+
+
+
+
+
+ {/* Handle */}
+
+
+ {/* Title */}
+ 连接仪器
+
+ {/* Guide */}
+
+ 1
+ 手机连接设备 WiFi 热点
+ Linking.openSettings()}>
+ 设置 →
+
+
+
+ 2
+ 确认参数后点击连接
+
+
+ {/* Inputs */}
+
+
+ IP 地址
+
+
+
+
+ 端 口
+
+
+
+
+ {/* Action */}
+ {!connected ? (
+
+ {connecting
+ ?
+ : 连 接 设 备}
+
+ ) : (
+
+
+
+ 已连接 {host}:{port}
+
+
+ 断 开
+
+
+ )}
+
+ {/* Log */}
+
+ ● 连接日志
+
+ {logs.map((l, i) => (
+ {l}
+ ))}
+
+
+
+
+
+ );
+}
+
+const S = StyleSheet.create({
+ backdrop: {
+ ...StyleSheet.absoluteFill,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ },
+ kav: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ },
+ sheet: {
+ backgroundColor: Colors.bg.surface,
+ borderTopLeftRadius: Radius.xl,
+ borderTopRightRadius: Radius.xl,
+ padding: Spacing.lg,
+ paddingBottom: Spacing.xl,
+ borderTopWidth: 1,
+ borderColor: Colors.bg.border,
+ },
+ handle: {
+ width: 36, height: 4, borderRadius: 2,
+ backgroundColor: Colors.bg.border,
+ alignSelf: 'center',
+ marginBottom: Spacing.md,
+ },
+ title: {
+ color: Colors.text.secondary,
+ fontSize: 15,
+ fontWeight: '700',
+ marginBottom: Spacing.md,
+ },
+
+ guideRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.sm,
+ marginBottom: 6,
+ },
+ stepBadge: {
+ width: 20, height: 20, borderRadius: 10,
+ backgroundColor: Colors.blue.bg,
+ justifyContent: 'center', alignItems: 'center',
+ borderWidth: 1, borderColor: Colors.blue.border,
+ },
+ stepNum: { color: Colors.blue.fg, fontSize: 10, fontWeight: '700' },
+ stepTxt: { color: Colors.text.muted, fontSize: 12, flex: 1 },
+ settingsBtn: {
+ backgroundColor: Colors.blue.bg, borderRadius: Radius.sm,
+ paddingHorizontal: 8, paddingVertical: 3,
+ borderWidth: 1, borderColor: Colors.blue.border,
+ },
+ settingsTxt: { color: Colors.blue.fg, fontSize: 11, fontWeight: '600' },
+
+ inputCard: {
+ backgroundColor: Colors.bg.raised,
+ borderRadius: Radius.md,
+ borderWidth: 1, borderColor: Colors.bg.border,
+ paddingHorizontal: Spacing.md,
+ marginBottom: Spacing.md,
+ },
+ inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm },
+ inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: Colors.bg.divider },
+ inputLabel: { color: Colors.text.muted, fontSize: 12, width: 52 },
+ input: {
+ flex: 1,
+ color: Colors.text.primary,
+ fontSize: 15,
+ fontWeight: '500',
+ paddingVertical: 4,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.bg.border,
+ },
+
+ connectBtn: {
+ backgroundColor: Colors.blue.bg,
+ borderRadius: Radius.lg,
+ paddingVertical: 15,
+ alignItems: 'center',
+ marginBottom: Spacing.md,
+ borderWidth: 1, borderColor: Colors.blue.fg,
+ },
+ connectBtnBusy: { borderColor: Colors.blue.border, backgroundColor: Colors.bg.raised },
+ connectBtnTxt: { color: Colors.blue.fg, fontSize: 15, fontWeight: '700', letterSpacing: 3 },
+
+ connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md },
+ connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
+ greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: Colors.green.fg },
+ connectedTxt: {
+ color: Colors.green.fg, fontWeight: '700', fontSize: 12,
+ fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
+ },
+ disconnectBtn: {
+ backgroundColor: Colors.red.bg, borderRadius: Radius.md,
+ paddingVertical: 8, paddingHorizontal: 14,
+ borderWidth: 1, borderColor: Colors.red.border,
+ },
+ disconnectTxt: { color: Colors.red.fg, fontSize: 12, fontWeight: '700', letterSpacing: 1 },
+
+ logBox: {
+ backgroundColor: Colors.bg.void,
+ borderRadius: Radius.md,
+ borderWidth: 1, borderColor: Colors.bg.border,
+ overflow: 'hidden',
+ maxHeight: 120,
+ },
+ logHeader: {
+ color: Colors.green.fg, fontSize: 9, fontWeight: '700', letterSpacing: 1.5,
+ paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5,
+ borderBottomWidth: 1, borderBottomColor: Colors.bg.divider,
+ },
+ logScroll: { padding: Spacing.sm },
+ logLine: {
+ color: Colors.text.ghost, fontSize: 10,
+ fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
+ marginBottom: 2, lineHeight: 15,
+ },
+ logLineLatest: { color: Colors.text.muted },
+});
diff --git a/src/design/tokens.ts b/src/design/tokens.ts
new file mode 100644
index 0000000..0965493
--- /dev/null
+++ b/src/design/tokens.ts
@@ -0,0 +1,33 @@
+export const Colors = {
+ bg: {
+ void: '#050508',
+ base: '#090912',
+ surface: '#0e0e1c',
+ raised: '#111120',
+ border: '#1a1a2a',
+ divider: '#141422',
+ },
+ text: {
+ primary: '#c0c0d8',
+ secondary: '#9090b8',
+ muted: '#4a4a6a',
+ ghost: '#2a2a4a',
+ },
+ green: { bg: '#0d2018', border: '#1a4a28', fg: '#3ddc84' },
+ blue: { bg: '#0d1a30', border: '#1a3a6e', fg: '#4a9eff' },
+ red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' },
+ teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' },
+ amber: { bg: '#1e1a08', border: '#4a3a1a', fg: '#ffd93d' },
+} as const;
+
+export const Spacing = {
+ xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32,
+} as const;
+
+export const Radius = {
+ sm: 6, md: 8, lg: 12, xl: 16,
+} as const;
+
+export const FontSize = {
+ xs: 9, sm: 10, md: 12, lg: 14, xl: 16,
+} as const;
diff --git a/src/hooks/useDevice.ts b/src/hooks/useDevice.ts
index 3a1a5f0..2fcbb83 100644
--- a/src/hooks/useDevice.ts
+++ b/src/hooks/useDevice.ts
@@ -39,6 +39,8 @@ export function useDevice() {
const ack = await deviceSetup(config);
if (ack.result !== 0x01) {
Alert.alert('配置失败', ack.reason || '设备返回失败');
+ } else {
+ useDeviceStore.getState().setConfigDirty(false);
}
return ack;
} catch (e: any) {
@@ -75,13 +77,14 @@ export function useDevice() {
}, []);
const stop = useCallback(async () => {
- setBusy(true);
+ // Optimistically transition to idle so the UI responds immediately.
+ // The ACK is sent best-effort; if the device doesn't reply the state
+ // is already correct on our side.
+ useDeviceStore.getState().setDeviceStatus('idle');
try {
await deviceStop();
- } catch (e: any) {
- Alert.alert('停止超时', e.message);
- } finally {
- setBusy(false);
+ } catch {
+ // best-effort — UI already reflects idle
}
}, []);
diff --git a/src/hooks/useWaveform.ts b/src/hooks/useWaveform.ts
index 35de54c..3206fcd 100644
--- a/src/hooks/useWaveform.ts
+++ b/src/hooks/useWaveform.ts
@@ -1,23 +1,29 @@
import { useMemo } from 'react';
import { useDataStore } from '../stores/dataStore';
+import { useDeviceStore } from '../stores/deviceStore';
+import type { MeasurementFrame } from '../protocol/types';
-const MAX_DISPLAY_POINTS = 512; // downsample to this many points for rendering
+const MAX_DISPLAY_POINTS = 512;
-// Downsample an array using peak-hold (envelope) method
+// sampleFreq code → Hz (from SAMPLE_FREQ_TABLE)
+const SAMPLE_FREQ_HZ: Record = {
+ 0x00: 250000, 0x01: 125000, 0x02: 62500, 0x03: 31250,
+ 0x04: 15600, 0x05: 7800, 0x06: 3900, 0x07: 1950,
+ 0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
+};
+
+// Peak-hold downsample: preserves signal envelope when reducing points.
function downsample(data: Float64Array, targetLen: number): Float64Array {
if (data.length <= targetLen) return data;
const ratio = data.length / targetLen;
const out = new Float64Array(targetLen);
for (let i = 0; i < targetLen; i++) {
const start = Math.floor(i * ratio);
- const end = Math.min(Math.floor((i + 1) * ratio), data.length);
+ const end = Math.min(Math.floor((i + 1) * ratio), data.length);
let maxAbs = 0;
let maxVal = 0;
for (let j = start; j < end; j++) {
- if (Math.abs(data[j]) > maxAbs) {
- maxAbs = Math.abs(data[j]);
- maxVal = data[j];
- }
+ if (Math.abs(data[j]) > maxAbs) { maxAbs = Math.abs(data[j]); maxVal = data[j]; }
}
out[i] = maxVal;
}
@@ -25,58 +31,80 @@ function downsample(data: Float64Array, targetLen: number): Float64Array {
}
export interface WaveformData {
- channels: Float64Array[]; // downsampled μV data per channel
- timeMs: Float64Array; // time axis in ms
- minLogUV: number; // log10 of min absolute non-zero value
- maxLogUV: number; // log10 of max absolute value
- sampleDepth: number; // original samples per channel
+ channels: Float64Array[]; // downsampled μV per channel
+ timeMs: Float64Array; // time axis in ms (physical)
+ totalTimeMs: number; // total window duration in ms
+ minLogUV: number; // log10(min |value|) for log scale
+ maxLogUV: number; // log10(max |value|) for log scale
+ minUV: number; // actual min value (signed) for linear scale
+ maxUV: number; // actual max value (signed) for linear scale
+ sampleDepth: number;
+}
+
+export function computeWaveformData(
+ frame: MeasurementFrame,
+ visibleChannels: boolean[],
+ sampleFreqCode: number,
+): WaveformData | null {
+ const { adcUV } = frame;
+ const sampleDepth = adcUV[0]?.length ?? 0;
+ if (sampleDepth === 0) return null;
+
+ const sampleHz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
+ const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
+
+ // Physical time axis
+ const timeMs = new Float64Array(displayLen);
+ for (let i = 0; i < displayLen; i++) {
+ timeMs[i] = (i / sampleHz) * 1000;
+ }
+ const totalTimeMs = ((displayLen - 1) / sampleHz) * 1000;
+
+ let logMin = Infinity;
+ let logMax = -Infinity;
+ let linMin = Infinity;
+ let linMax = -Infinity;
+
+ const channels: Float64Array[] = adcUV.map((ch, idx) => {
+ if (!visibleChannels[idx]) return new Float64Array(displayLen);
+ const ds = downsample(ch, displayLen);
+ for (let i = 0; i < ds.length; i++) {
+ const v = ds[i];
+ const absV = Math.abs(v);
+ if (absV > 1e-9) {
+ logMin = Math.min(logMin, absV);
+ logMax = Math.max(logMax, absV);
+ }
+ linMin = Math.min(linMin, v);
+ linMax = Math.max(linMax, v);
+ }
+ return ds;
+ });
+
+ if (!isFinite(logMin)) logMin = 1e-3;
+ if (!isFinite(logMax)) logMax = 1e6;
+ if (!isFinite(linMin)) linMin = -1;
+ if (!isFinite(linMax)) linMax = 1;
+
+ // Symmetric linear range for cleaner display
+ const linPeak = Math.max(Math.abs(linMin), Math.abs(linMax), 1);
+ linMin = -linPeak;
+ linMax = linPeak;
+
+ return {
+ channels, timeMs, totalTimeMs,
+ minLogUV: Math.log10(logMin), maxLogUV: Math.log10(logMax),
+ minUV: linMin, maxUV: linMax,
+ sampleDepth,
+ };
}
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
- const frame = useDataStore((s) => s.currentFrame);
+ const frame = useDataStore((s) => s.currentFrame);
+ const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
return useMemo(() => {
if (!frame) return null;
-
- const { adcUV, meta } = frame;
- const sampleDepth = adcUV[0]?.length ?? 0;
- if (sampleDepth === 0) return null;
-
- // Build time axis in ms using sample freq (approximation)
- // We don't have actual sample rate here, so we use index/total
- // The real time axis depends on the sendFreq and sampleFreq config
- // Use normalised 0–1 for now, App can scale by actual period
- const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
- const timeMs = new Float64Array(displayLen);
- for (let i = 0; i < displayLen; i++) {
- timeMs[i] = i / displayLen; // normalised 0–1 (multiply by period in ms externally)
- }
-
- let globalMin = Infinity;
- let globalMax = -Infinity;
-
- const channels: Float64Array[] = adcUV.map((ch, idx) => {
- if (!visibleChannels[idx]) return new Float64Array(displayLen);
- const ds = downsample(ch, displayLen);
- for (let i = 0; i < ds.length; i++) {
- const absV = Math.abs(ds[i]);
- if (absV > 1e-9) {
- globalMin = Math.min(globalMin, absV);
- globalMax = Math.max(globalMax, absV);
- }
- }
- return ds;
- });
-
- if (!isFinite(globalMin)) globalMin = 1e-3;
- if (!isFinite(globalMax)) globalMax = 1e6;
-
- return {
- channels,
- timeMs,
- minLogUV: Math.log10(globalMin),
- maxLogUV: Math.log10(globalMax),
- sampleDepth,
- };
- }, [frame, visibleChannels]);
+ return computeWaveformData(frame, visibleChannels, sampleFreqCode);
+ }, [frame, visibleChannels, sampleFreqCode]);
}
diff --git a/src/protocol/types.ts b/src/protocol/types.ts
index 3478fef..32ff7ac 100644
--- a/src/protocol/types.ts
+++ b/src/protocol/types.ts
@@ -58,10 +58,11 @@ export interface MeasurementFrame {
adcRaw: Int32Array[];
// Converted to μV: raw / accNum / gain
adcUV: Float64Array[];
- accNum: number; // stack count at time of reception
- gain: number; // gain multiplier at time of reception
- timestamp: number;// local ms timestamp when received
- frameId: number; // sequential frame counter
+ accNum: number; // stack count at time of reception
+ gain: number; // gain multiplier at time of reception
+ sampleFreqCode:number; // ADC sample frequency code (from SetupConfig)
+ timestamp: number; // local ms timestamp when received
+ frameId: number; // sequential frame counter
}
// Split frame session state
@@ -81,3 +82,13 @@ export type ConnectionStatus =
// Device running state
export type DeviceStatus = 'idle' | 'running' | 'single';
+
+// ── Gate config (profile screen, persisted in deviceStore) ─────────────────
+export type GateSpacing = 'log' | 'linear';
+
+export interface GateConfig {
+ tStart: number; // μs
+ tEnd: number; // μs
+ count: number;
+ spacing: GateSpacing;
+}
diff --git a/src/services/BinLoader.ts b/src/services/BinLoader.ts
new file mode 100644
index 0000000..b822a74
--- /dev/null
+++ b/src/services/BinLoader.ts
@@ -0,0 +1,203 @@
+/**
+ * TEMF v1 binary format
+ *
+ * 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)
+ *
+ * Data (after header):
+ * int32 LE, row-major: channelNum rows × sampleDepth columns
+ * Values are raw ADC counts; calibrated μV = raw / accNum / AMP_GAIN[ampRatio]
+ */
+
+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;
+
+// ── Public types ────────────────────────────────────────────────────────────
+
+export interface LoadedBin {
+ adcUV: Float64Array[];
+ sampleDepth: number;
+ channelNum: number;
+ sampleFreqCode: number;
+ accNum: number;
+ ampRatio: number;
+ srcMode: number;
+ timestamp: number;
+ latitude: number;
+ longitude: number;
+ utc: number;
+ altitude: number;
+ batteryVolt: number;
+ temperature: number;
+ current: number;
+ gpsStatus: number;
+ sdStatus: number;
+ roll: number;
+ pitch: number;
+ yaw: number;
+}
+
+// ── Base64 ↔ Uint8Array helpers ─────────────────────────────────────────────
+
+export function u8ToBase64(u8: Uint8Array): string {
+ const chunk = 8192;
+ let str = '';
+ for (let i = 0; i < u8.length; i += chunk) {
+ str += String.fromCharCode(...u8.subarray(i, Math.min(i + chunk, u8.length)));
+ }
+ return btoa(str);
+}
+
+export function base64ToU8(b64: string): Uint8Array {
+ const binary = atob(b64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+// ── Pure encode / decode ────────────────────────────────────────────────────
+
+/** Encode a MeasurementFrame into TEMF v1 bytes. No IO. */
+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 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
+
+ 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; }
+ }
+ return new Uint8Array(buf);
+}
+
+/** Decode TEMF v1 bytes into a LoadedBin. Returns null if invalid. No IO. */
+export function decodeBin(bytes: Uint8Array): LoadedBin | null {
+ if (bytes.length < HEADER_SIZE) 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;
+
+ 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;
+
+ const expected = HEADER_SIZE + channelNum * sampleDepth * 4;
+ if (bytes.length < expected) return null;
+
+ const gain = AMP_GAIN[ampRatio] ?? 1;
+ const acc = Math.max(accNum, 1);
+ const adcUV: Float64Array[] = [];
+ let off = HEADER_SIZE;
+ for (let ch = 0; ch < channelNum; ch++) {
+ const channel = new Float64Array(sampleDepth);
+ for (let i = 0; i < sampleDepth; i++) {
+ channel[i] = dv.getInt32(off, true) / acc / gain;
+ off += 4;
+ }
+ adcUV.push(channel);
+ }
+
+ return {
+ adcUV, sampleDepth, channelNum, sampleFreqCode,
+ accNum, ampRatio, srcMode, timestamp,
+ latitude, longitude, utc, altitude,
+ batteryVolt, temperature, current,
+ gpsStatus, sdStatus, roll, pitch, yaw,
+ };
+}
+
+// ── File IO ─────────────────────────────────────────────────────────────────
+
+export async function readFileBytes(path: string): Promise {
+ try {
+ const b64 = await FileSystem.readAsStringAsync(path, {
+ encoding: FileSystem.EncodingType.Base64,
+ });
+ return base64ToU8(b64);
+ } catch {
+ return null;
+ }
+}
+
+export async function writeFileBytes(path: string, data: Uint8Array): Promise {
+ await FileSystem.writeAsStringAsync(path, u8ToBase64(data), {
+ encoding: FileSystem.EncodingType.Base64,
+ });
+}
+
+export async function saveBinFile(path: string, frame: MeasurementFrame): Promise {
+ await writeFileBytes(path, encodeBin(frame));
+}
+
+export async function loadBinFile(path: string): Promise {
+ const bytes = await readFileBytes(path);
+ return bytes ? decodeBin(bytes) : null;
+}
diff --git a/src/services/DeviceService.ts b/src/services/DeviceService.ts
index 17bd881..a4abb6a 100644
--- a/src/services/DeviceService.ts
+++ b/src/services/DeviceService.ts
@@ -17,8 +17,6 @@ import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
-let frameCounter = 0;
-
// Pending ACK promise resolver keyed by func code
const pendingAcks = new Map void>();
@@ -33,7 +31,7 @@ export function handleIncomingFrame(frame: RawFrame) {
const dataStore = useDataStore.getState();
switch (func) {
- // ── ACK responses ──────────────────────────────────────────────
+ // ── ACK responses ──────────────────────────────────────────────────
case FuncCode.SETUP_ACK:
case FuncCode.CONTINUOUS_ACK:
case FuncCode.SINGLE_ACK:
@@ -97,6 +95,10 @@ function processMeasurementPayload(
devStore: ReturnType,
dataStore: ReturnType,
) {
+ // Discard frames that arrive after we've already stopped — these are
+ // in-flight packets from the device before it processes the STOP command.
+ if (devStore.deviceStatus === 'idle') return;
+
const meta = parseMetadata(payload);
if (!meta) return;
@@ -109,12 +111,27 @@ function processMeasurementPayload(
adcRaw,
adcUV,
accNum,
- gain: cfg.ampRatio,
- timestamp: Date.now(),
- frameId: frameCounter++,
+ gain: cfg.ampRatio,
+ sampleFreqCode: cfg.sampleFreq,
+ timestamp: Date.now(),
+ frameId: dataStore.nextFrameId(),
};
dataStore.addFrame(measurementFrame);
+
+ devStore.updateTelemetry({
+ batteryVolt: meta.batteryVolt,
+ temperature: meta.temperature,
+ gpsStatus: meta.gpsStatus,
+ sdStatus: meta.sdStatus,
+ frameId: measurementFrame.frameId,
+ });
+
+ // Single acquisition completes as soon as the data frame arrives —
+ // automatically send STOP so the device returns to idle.
+ if (devStore.deviceStatus === 'single') {
+ void deviceStop();
+ }
}
// ── Public API ──────────────────────────────────────────────────────────────
diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts
new file mode 100644
index 0000000..f83bcb2
--- /dev/null
+++ b/src/services/StorageService.ts
@@ -0,0 +1,497 @@
+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,
+ };
+}
diff --git a/src/services/TemBundle.ts b/src/services/TemBundle.ts
new file mode 100644
index 0000000..7640ef0
--- /dev/null
+++ b/src/services/TemBundle.ts
@@ -0,0 +1,101 @@
+import { zip, unzip } from 'fflate';
+import type { AsyncZippable, AsyncZipOptions } from 'fflate';
+import type { SetupConfig, GateConfig } from '../protocol/types';
+
+// ── Manifest schema ────────────────────────────────────────────────────────
+
+export interface SessionManifest {
+ id: string;
+ created_at: number;
+ frame_count: number;
+ device_config: SetupConfig;
+}
+
+export interface ProjectManifest {
+ schema_version: '1.0';
+ exported_at: string;
+ project: {
+ id: string;
+ name: string;
+ created_at: number;
+ };
+ device_config: SetupConfig;
+ gate_config: GateConfig;
+ sessions: SessionManifest[];
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+const ENC = new TextEncoder();
+const DEC = new TextDecoder();
+
+function promiseZip(files: AsyncZippable): Promise {
+ return new Promise((resolve, reject) => {
+ zip(files, (err, data) => (err ? reject(err) : resolve(data)));
+ });
+}
+
+function promiseUnzip(data: Uint8Array): Promise> {
+ return new Promise((resolve, reject) => {
+ unzip(data, (err, files) => (err ? reject(err) : resolve(files)));
+ });
+}
+
+// ── Public API ─────────────────────────────────────────────────────────────
+
+/**
+ * Pack a .tem bundle (DEFLATE-compressed ZIP).
+ * bins: relative path → raw bin bytes, e.g. "frames/20260607_042316/000001.bin"
+ */
+export async function packBundle(
+ manifest: ProjectManifest,
+ bins: Record,
+): Promise {
+ const opts: AsyncZipOptions = { level: 6 };
+ const files: AsyncZippable = {
+ 'project.json': [ENC.encode(JSON.stringify(manifest, null, 2)), opts],
+ };
+ for (const [path, data] of Object.entries(bins)) {
+ files[path] = [data, opts];
+ }
+ return promiseZip(files);
+}
+
+/**
+ * Unpack a .tem bundle.
+ * Returns the manifest and raw bin bytes keyed by relative path.
+ */
+export async function parseBundle(data: Uint8Array): Promise<{
+ manifest: ProjectManifest;
+ bins: Record;
+}> {
+ const files = await promiseUnzip(data);
+
+ const manifestBytes = files['project.json'];
+ if (!manifestBytes) throw new Error('.tem 包缺少 project.json');
+
+ const manifest = JSON.parse(DEC.decode(manifestBytes)) as ProjectManifest;
+ if (manifest.schema_version !== '1.0') {
+ throw new Error(`不支持的版本: schema_version=${manifest.schema_version}`);
+ }
+
+ const bins: Record = {};
+ for (const [path, bytes] of Object.entries(files)) {
+ if (path !== 'project.json') bins[path] = bytes;
+ }
+
+ return { manifest, bins };
+}
+
+/** Extract session ID and frame ID from a bundle bin path. */
+export function parseBinPath(relPath: string): { sessionId: string; frameId: number } | null {
+ // Expected: "frames/{sessionId}/{frameId:06d}.bin"
+ const m = relPath.match(/^frames\/([^/]+)\/(\d+)\.bin$/);
+ if (!m) return null;
+ return { sessionId: m[1], frameId: parseInt(m[2], 10) };
+}
+
+/** Build the bundle-relative path for a frame bin. */
+export function bundleBinPath(sessionId: string, frameId: number): string {
+ return `frames/${sessionId}/${String(frameId).padStart(6, '0')}.bin`;
+}
diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts
index 65d1d14..319ac65 100644
--- a/src/stores/connectionStore.ts
+++ b/src/stores/connectionStore.ts
@@ -1,25 +1,42 @@
import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
import type { ConnectionStatus } from '../protocol/types';
+import { fileStorage } from '../utils/storage';
interface ConnectionState {
- status: ConnectionStatus;
- host: string;
- port: number;
- lastError: string;
- setStatus: (s: ConnectionStatus) => void;
- setHost: (h: string) => void;
- setPort: (p: number) => void;
- setError: (e: string) => void;
+ status: ConnectionStatus;
+ host: string;
+ port: number;
+ lastError: string;
+ modalVisible: boolean;
+ setStatus: (s: ConnectionStatus) => void;
+ setHost: (h: string) => void;
+ setPort: (p: number) => void;
+ setError: (e: string) => void;
+ showModal: () => void;
+ hideModal: () => void;
}
-export const useConnectionStore = create((set) => ({
- status: 'disconnected',
- host: DEFAULT_DEVICE_IP,
- port: DEFAULT_TCP_PORT,
- lastError: '',
- setStatus: (status) => set({ status }),
- setHost: (host) => set({ host }),
- setPort: (port) => set({ port }),
- setError: (lastError) => set({ lastError, status: 'error' }),
-}));
+export const useConnectionStore = create()(
+ persist(
+ (set) => ({
+ status: 'disconnected',
+ host: DEFAULT_DEVICE_IP,
+ port: DEFAULT_TCP_PORT,
+ lastError: '',
+ modalVisible: false,
+ setStatus: (status) => set({ status }),
+ setHost: (host) => set({ host }),
+ setPort: (port) => set({ port }),
+ setError: (lastError) => set({ lastError, status: 'error' }),
+ showModal: () => set({ modalVisible: true }),
+ hideModal: () => set({ modalVisible: false }),
+ }),
+ {
+ name: 'connection-config',
+ storage: createJSONStorage(() => fileStorage),
+ partialize: (state) => ({ host: state.host, port: state.port }),
+ },
+ ),
+);
diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts
index 730a9cc..1a2a122 100644
--- a/src/stores/dataStore.ts
+++ b/src/stores/dataStore.ts
@@ -1,29 +1,114 @@
import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
import type { MeasurementFrame } from '../protocol/types';
+import * as StorageService from '../services/StorageService';
+import { fileStorage } from '../utils/storage';
-const MAX_HISTORY = 500; // keep last 500 frames in memory
+const MAX_HISTORY = 500;
interface DataState {
currentFrame: MeasurementFrame | null;
- history: MeasurementFrame[]; // newest first
+ history: MeasurementFrame[];
sessionId: string;
+ projectId: string | null;
+ _frameCounter: number;
+ nextFrameId: () => number;
addFrame: (frame: MeasurementFrame) => void;
+ deleteFrame: (frameId: number) => void;
clearHistory: () => void;
- newSession: () => void;
+ /** Start a new line (session), optionally under a project. */
+ newSession: (projectId?: string | null) => Promise;
+ /** Resume an existing session from a previous run. In-memory history starts
+ * empty; the frame counter resumes from the DB max so IDs don't collide. */
+ resumeSession: (sessionId: string, projectId?: string | null) => Promise;
+ /** Change the project association of the current session. */
+ setProject: (projectId: string | null) => Promise;
+ init: () => Promise;
}
-export const useDataStore = create((set) => ({
- currentFrame: null,
- history: [],
- sessionId: generateSessionId(),
- addFrame: (frame) =>
- set((s) => ({
- currentFrame: frame,
- history: [frame, ...s.history].slice(0, MAX_HISTORY),
- })),
- clearHistory: () => set({ history: [], currentFrame: null }),
- newSession: () => set({ sessionId: generateSessionId(), history: [], currentFrame: null }),
-}));
+export const useDataStore = create()(
+ persist(
+ (set, get) => ({
+ currentFrame: null,
+ history: [],
+ sessionId: generateSessionId(),
+ projectId: null,
+ _frameCounter: 0,
+
+ nextFrameId: () => {
+ const id = get()._frameCounter;
+ set((s) => ({ _frameCounter: s._frameCounter + 1 }));
+ return id;
+ },
+
+ addFrame: (frame) => {
+ set((s) => ({
+ currentFrame: frame,
+ history: [frame, ...s.history].slice(0, MAX_HISTORY),
+ }));
+ void StorageService.persistFrame(frame, get().sessionId);
+ },
+
+ deleteFrame: (frameId) => {
+ const { sessionId, history, currentFrame } = get();
+ void StorageService.deleteFrame(frameId, sessionId);
+ const next = history.filter((f) => f.frameId !== frameId);
+ set({
+ history: next,
+ currentFrame: currentFrame?.frameId === frameId ? (next[0] ?? null) : currentFrame,
+ });
+ },
+
+ clearHistory: () => set({ history: [], currentFrame: null }),
+
+ newSession: async (projectId) => {
+ const id = generateSessionId();
+ const pid = projectId !== undefined ? projectId : get().projectId;
+ await StorageService.ensureSession(id, pid);
+ set({ sessionId: id, projectId: pid, history: [], currentFrame: null, _frameCounter: 0 });
+ },
+
+ resumeSession: async (sessionId, projectId) => {
+ const pid = projectId !== undefined ? projectId : get().projectId;
+ await StorageService.ensureSession(sessionId, pid);
+ const maxId = await StorageService.getMaxFrameId(sessionId);
+ // In-memory history intentionally starts empty — past frames are in SQLite
+ // and visible in the Profile / SessionHistory sections.
+ set({
+ sessionId,
+ projectId: pid ?? null,
+ history: [],
+ currentFrame: null,
+ _frameCounter: maxId + 1,
+ });
+ },
+
+ setProject: async (projectId) => {
+ const { sessionId } = get();
+ await StorageService.assignSessionToProject(sessionId, projectId);
+ set({ projectId });
+ },
+
+ init: async () => {
+ await StorageService.initStorage();
+ const { sessionId, projectId } = get();
+ await StorageService.ensureSession(sessionId, projectId);
+ const maxId = await StorageService.getMaxFrameId(sessionId);
+ set({ _frameCounter: maxId + 1 });
+ },
+ }),
+ {
+ name: 'data-session',
+ storage: createJSONStorage(() => fileStorage),
+ // Only persist identity fields. Runtime data (frames, history) is always
+ // rebuilt from SQLite on demand.
+ partialize: (state) => ({
+ sessionId: state.sessionId,
+ projectId: state.projectId,
+ }),
+ },
+ ),
+);
function generateSessionId(): string {
const now = new Date();
diff --git a/src/stores/deviceStore.ts b/src/stores/deviceStore.ts
index df1f86f..331c252 100644
--- a/src/stores/deviceStore.ts
+++ b/src/stores/deviceStore.ts
@@ -1,6 +1,15 @@
import { create } from 'zustand';
-import type { SetupConfig, DeviceStatus } from '../protocol/types';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import type { SetupConfig, DeviceStatus, GateConfig } from '../protocol/types';
import { DataChannel } from '../protocol/constants';
+import { fileStorage } from '../utils/storage';
+
+const DEFAULT_GATE_CONFIG: GateConfig = {
+ tStart: 100,
+ tEnd: 3000,
+ count: 10,
+ spacing: 'log',
+};
const DEFAULT_CONFIG: SetupConfig = {
channelNum: 3,
@@ -21,18 +30,25 @@ const DEFAULT_CONFIG: SetupConfig = {
};
interface DeviceState {
- config: SetupConfig;
+ // ── Device config (persisted) ──────────────────────────────────────────────
+ config: SetupConfig;
+ gateConfig: GateConfig;
+ configDirty: boolean; // true when config changed but not yet sent to device
+ // ── Runtime status (not persisted) ────────────────────────────────────────
deviceStatus: DeviceStatus;
- batteryVolt: number; // raw ADC
- temperature: number; // raw ADC
- gpsStatus: number;
- sdStatus: number;
- frameId: number; // last received frame ID
- accCount: number; // stacking progress
- setConfig: (cfg: SetupConfig) => void;
- updateConfig: (partial: Partial) => void;
- setDeviceStatus: (s: DeviceStatus) => void;
- updateTelemetry: (data: {
+ // ── Telemetry (not persisted) ─────────────────────────────────────────────
+ batteryVolt: number;
+ temperature: number;
+ gpsStatus: number;
+ sdStatus: number;
+ frameId: number;
+ accCount: number;
+ setConfig: (cfg: SetupConfig) => void;
+ updateConfig: (partial: Partial) => void;
+ setGateConfig: (cfg: GateConfig) => void;
+ setConfigDirty: (v: boolean) => void;
+ setDeviceStatus: (s: DeviceStatus) => void;
+ updateTelemetry: (data: {
batteryVolt?: number;
temperature?: number;
gpsStatus?: number;
@@ -42,18 +58,30 @@ interface DeviceState {
}) => void;
}
-export const useDeviceStore = create((set) => ({
- config: DEFAULT_CONFIG,
- deviceStatus: 'idle',
- batteryVolt: 0,
- temperature: 0,
- gpsStatus: 0,
- sdStatus: 0,
- frameId: 0,
- accCount: 0,
- setConfig: (config) => set({ config }),
- updateConfig: (partial) =>
- set((s) => ({ config: { ...s.config, ...partial } })),
- setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
- updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
-}));
+export const useDeviceStore = create()(
+ persist(
+ (set) => ({
+ config: DEFAULT_CONFIG,
+ gateConfig: DEFAULT_GATE_CONFIG,
+ configDirty: false,
+ deviceStatus: 'idle',
+ batteryVolt: 0,
+ temperature: 0,
+ gpsStatus: 0,
+ sdStatus: 0,
+ frameId: 0,
+ accCount: 0,
+ setConfig: (config) => set({ config, configDirty: true }),
+ updateConfig: (partial) => set((s) => ({ config: { ...s.config, ...partial }, configDirty: true })),
+ setGateConfig: (gateConfig) => set({ gateConfig }),
+ setConfigDirty: (configDirty) => set({ configDirty }),
+ setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
+ updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
+ }),
+ {
+ name: 'device-config',
+ storage: createJSONStorage(() => fileStorage),
+ partialize: (state) => ({ config: state.config, gateConfig: state.gateConfig }),
+ },
+ ),
+);
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
new file mode 100644
index 0000000..807f9d6
--- /dev/null
+++ b/src/utils/storage.ts
@@ -0,0 +1,32 @@
+import * as FileSystem from 'expo-file-system/legacy';
+
+const BASE = FileSystem.documentDirectory ?? '';
+
+// Async key-value storage backed by individual JSON files.
+// Compatible with zustand/middleware createJSONStorage().
+export const fileStorage = {
+ getItem: async (name: string): Promise => {
+ try {
+ const path = `${BASE}zs_${name}.json`;
+ const info = await FileSystem.getInfoAsync(path);
+ if (!info.exists) return null;
+ return await FileSystem.readAsStringAsync(path);
+ } catch {
+ return null;
+ }
+ },
+
+ setItem: async (name: string, value: string): Promise => {
+ try {
+ const path = `${BASE}zs_${name}.json`;
+ await FileSystem.writeAsStringAsync(path, value);
+ } catch { /* silent — don't crash on storage failure */ }
+ },
+
+ removeItem: async (name: string): Promise => {
+ try {
+ const path = `${BASE}zs_${name}.json`;
+ await FileSystem.deleteAsync(path, { idempotent: true });
+ } catch { /* silent */ }
+ },
+};
diff --git a/tools/mock_device.py b/tools/mock_device.py
new file mode 100644
index 0000000..09e6db5
--- /dev/null
+++ b/tools/mock_device.py
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+"""
+TEM 下位机模拟器 — 用于 App 调试
+用法:
+ python tools/mock_device.py
+ python tools/mock_device.py --host 0.0.0.0 --port 4321
+ python tools/mock_device.py --channels 3 --freq 2 # 3通道, 2Hz
+
+在 App 连接界面将 IP 改为本机局域网 IP(或 10.0.2.2 若在 Android 模拟器中)。
+"""
+
+import socket
+import struct
+import threading
+import time
+import math
+import random
+import argparse
+import sys
+from datetime import datetime
+
+# ── 协议常量 ──────────────────────────────────────────────────────────────────
+
+MAGIC = b'\x68\x68\xff\xff'
+FRAME_FLAG = 0xFE
+
+class FC:
+ SETUP_REQ = 0x01
+ CONTINUOUS_REQ = 0x02
+ SINGLE_REQ = 0x03
+ STOP_REQ = 0x04
+ ACTIVE_REQ = 0x05
+ SPLITFRAME_REQ = 0x08
+ SETUP_ACK = 0x81
+ CONTINUOUS_ACK = 0x82
+ SINGLE_ACK = 0x83
+ STOP_ACK = 0x84
+ DATA_ACK = 0x85
+
+_FC_NAME = {
+ 0x01: 'SETUP_REQ', 0x02: 'CONTINUOUS_REQ', 0x03: 'SINGLE_REQ',
+ 0x04: 'STOP_REQ', 0x05: 'ACTIVE_REQ', 0x08: 'SPLITFRAME_REQ',
+ 0x81: 'SETUP_ACK', 0x82: 'CONTINUOUS_ACK', 0x83: 'SINGLE_ACK',
+ 0x84: 'STOP_ACK', 0x85: 'DATA_ACK',
+}
+
+# sendFreq code → Hz
+SEND_FREQ_HZ = {
+ 0x00: 0.5, 0x01: 1.0, 0x02: 2.0, 0x03: 4.0,
+ 0x04: 8.0, 0x05: 12.5, 0x06: 16.0, 0x07: 25.0,
+ 0x08: 32.0, 0x09: 50.0, 0x0a: 64.0,
+}
+
+AMP_GAIN = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 1.0]
+
+# ── 帧构建 ──────────────────────────────────────────────────────────────────
+
+def build_frame(func: int, payload: bytes) -> bytes:
+ header = MAGIC + struct.pack(' bytes:
+ payload = bytearray(54)
+ payload[0] = 0x01 if ok else 0x02
+ if reason:
+ encoded = reason.encode('ascii', errors='replace')[:31]
+ payload[22:22 + len(encoded)] = encoded
+ return build_frame(func_ack, bytes(payload))
+
+def build_data_frame(cfg: dict, phase: float) -> bytes:
+ """
+ 构建 DATA_ACK 帧,包含 54 字节元数据 + channelNum*sampleDepth 个 int32 ADC 采样。
+ phase: 当前帧的相位偏移(秒),用于生成连续正弦波形。
+ """
+ ch_num = cfg['channelNum']
+ depth = cfg['sampleDepth']
+ amp_idx = cfg['ampRatio']
+ acc_num = max(cfg['accNum'], 1)
+ gain = AMP_GAIN[amp_idx] if amp_idx < len(AMP_GAIN) else 1.0
+
+ # 54 字节元数据(与 parser.ts parseMetadata 对齐)
+ meta = bytearray(54)
+ meta[0] = 1 # devId
+ struct.pack_into('= 10:
+ # 搜索帧头
+ idx = -1
+ for i in range(len(self.buf) - 3):
+ if self.buf[i:i+4] == b'\x68\x68\xff\xff':
+ idx = i
+ break
+ if idx == -1:
+ self.buf = bytearray(self.buf[-3:]) if len(self.buf) >= 3 else bytearray()
+ break
+ if idx > 0:
+ self.buf = self.buf[idx:]
+ if len(self.buf) < 10:
+ break
+ payload_len = struct.unpack_from(' 400 * 1024: # 异常长度,跳过此帧头
+ self.buf = self.buf[4:]
+ continue
+ total = 10 + payload_len
+ if len(self.buf) < total:
+ break
+ func = self.buf[5]
+ payload = bytes(self.buf[10:total])
+ frames.append((func, payload))
+ self.buf = self.buf[total:]
+ return frames
+
+
+# ── 客户端会话 ──────────────────────────────────────────────────────────────
+
+class Session:
+ def __init__(self, sock: socket.socket, addr, cli_cfg: dict):
+ self.sock = sock
+ self.addr = addr
+ self.parser = FrameParser()
+ self.phase = 0.0 # 当前正弦相位(秒)
+ self.running = False # 连续采集中?
+ self._lock = threading.Lock()
+
+ # 当前设备配置(可被 SETUP_REQ 覆盖)
+ self.cfg = {
+ 'channelNum': cli_cfg.get('channels', 6),
+ 'sendFreq': cli_cfg.get('freq_code', 0x01),
+ 'sampleFreq': 0x03,
+ 'sampleDepth': cli_cfg.get('depth', 256),
+ 'accNum': 1,
+ 'ampRatio': 3, # 1×
+ 'sourceMode': 0x00,
+ }
+
+ def _ts(self):
+ return datetime.now().strftime('%H:%M:%S.%f')[:-3]
+
+ def log(self, msg: str, direction: str = ' '):
+ tag = f'{direction} [{self.addr[0]}:{self.addr[1]}]'
+ print(f'[{self._ts()}] {tag} {msg}')
+
+ def send(self, data: bytes) -> bool:
+ try:
+ self.sock.sendall(data)
+ return True
+ except OSError:
+ return False
+
+ # ── 连续发送线程 ──────────────────────────────────────────────────────
+
+ def _data_loop(self):
+ freq_hz = SEND_FREQ_HZ.get(self.cfg['sendFreq'], 1.0)
+ interval = 1.0 / freq_hz
+ self.log(f'开始连续推送 @ {freq_hz} Hz(间隔 {interval*1000:.0f} ms)', '⇄')
+ while self.running:
+ t0 = time.monotonic()
+ frame = build_data_frame(self.cfg, self.phase)
+ if not self.send(frame):
+ break
+ payload_size = len(frame) - 10
+ self.log(f'DATA_ACK ch={self.cfg["channelNum"]} '
+ f'depth={self.cfg["sampleDepth"]} '
+ f'payload={payload_size}B', '→')
+ self.phase += interval
+ elapsed = time.monotonic() - t0
+ sleep_t = interval - elapsed
+ if sleep_t > 0:
+ time.sleep(sleep_t)
+ self.log('连续推送已停止', '⇄')
+
+ def start_continuous(self):
+ if self.running:
+ return
+ self.running = True
+ t = threading.Thread(target=self._data_loop, daemon=True)
+ t.start()
+
+ def stop_continuous(self):
+ self.running = False
+
+ # ── 帧处理 ────────────────────────────────────────────────────────────
+
+ def _parse_setup(self, payload: bytes):
+ if len(payload) < 14:
+ return
+ self.cfg['channelNum'] = max(1, min(6, payload[0]))
+ self.cfg['sendFreq'] = payload[1]
+ self.cfg['sampleFreq'] = payload[2]
+ depth = struct.unpack_from(' 0 else 256
+ acc_flags = struct.unpack_from('> 2) & 0x3FFF
+ self.cfg['accNum'] = acc_num if acc_num > 0 else 1
+ self.cfg['ampRatio'] = payload[7]
+ self.cfg['sourceMode'] = payload[13] if len(payload) > 13 else 0
+ prefix = ''
+ if len(payload) >= 54:
+ raw = payload[38:54]
+ prefix = raw.split(b'\x00', 1)[0].decode('ascii', errors='replace')
+ freq_hz = SEND_FREQ_HZ.get(self.cfg['sendFreq'], 1.0)
+ self.log(
+ f'SETUP ch={self.cfg["channelNum"]} '
+ f'freq={freq_hz}Hz depth={self.cfg["sampleDepth"]} '
+ f'acc={self.cfg["accNum"]} amp=0x{self.cfg["ampRatio"]:02X} '
+ f'prefix="{prefix}"', '←'
+ )
+
+ def handle(self, func: int, payload: bytes):
+ name = _FC_NAME.get(func, f'0x{func:02X}')
+
+ if func == FC.SETUP_REQ:
+ self._parse_setup(payload)
+ self.send(build_ack(FC.SETUP_ACK))
+ self.log('SETUP_ACK OK', '→')
+
+ elif func == FC.CONTINUOUS_REQ:
+ self.log('CONTINUOUS_REQ', '←')
+ self.send(build_ack(FC.CONTINUOUS_ACK))
+ self.log('CONTINUOUS_ACK OK', '→')
+ self.start_continuous()
+
+ elif func == FC.SINGLE_REQ:
+ self.log('SINGLE_REQ', '←')
+ self.send(build_ack(FC.SINGLE_ACK))
+ self.log('SINGLE_ACK OK', '→')
+ frame = build_data_frame(self.cfg, self.phase)
+ self.send(frame)
+ self.log(f'DATA_ACK (单次) {len(frame)-10}B', '→')
+ self.phase += 1.0
+
+ elif func == FC.STOP_REQ:
+ self.log('STOP_REQ', '←')
+ self.stop_continuous()
+ self.send(build_ack(FC.STOP_ACK))
+ self.log('STOP_ACK OK', '→')
+
+ elif func == FC.ACTIVE_REQ:
+ pass # keepalive,无需回应
+
+ elif func == FC.SPLITFRAME_REQ:
+ self.log(f'SPLITFRAME_REQ(未实现,忽略)', '←')
+
+ else:
+ self.log(f'未知帧 0x{func:02X} ({len(payload)}B payload)', '←')
+
+ # ── 主循环 ────────────────────────────────────────────────────────────
+
+ def run(self):
+ self.log('已连接', '✓')
+ try:
+ while True:
+ data = self.sock.recv(4096)
+ if not data:
+ break
+ for func, payload in self.parser.feed(data):
+ self.handle(func, payload)
+ except OSError:
+ pass
+ finally:
+ self.stop_continuous()
+ self.sock.close()
+ self.log('已断开', '✗')
+
+
+# ── 服务器主入口 ────────────────────────────────────────────────────────────
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='TEM 下位机模拟器',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+连接方式:
+ Android 模拟器 → App IP 填 10.0.2.2
+ 真机(同 WiFi) → App IP 填本机局域网 IP(运行 ipconfig 查看)
+ Expo Web/桌面 → App IP 填 127.0.0.1
+ """,
+ )
+ parser.add_argument('--host', default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
+ parser.add_argument('--port', type=int, default=4321, help='监听端口 (默认: 4321)')
+ parser.add_argument('--channels', type=int, default=6, help='默认通道数 1-6 (默认: 6)')
+ parser.add_argument('--freq', type=float, default=1.0, help='默认发送频率 Hz (默认: 1.0)')
+ parser.add_argument('--depth', type=int, default=256, help='默认采样深度 (默认: 256)')
+ args = parser.parse_args()
+
+ # 找到最接近的 freq code
+ freq_code = min(SEND_FREQ_HZ, key=lambda k: abs(SEND_FREQ_HZ[k] - args.freq))
+ actual_freq = SEND_FREQ_HZ[freq_code]
+
+ cli_cfg = {
+ 'channels': min(6, max(1, args.channels)),
+ 'freq_code': freq_code,
+ 'depth': max(16, args.depth),
+ }
+
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((args.host, args.port))
+ srv.listen(5)
+
+ print(f'\n TEM 下位机模拟器')
+ print(f' ─────────────────────────────────────────')
+ print(f' 监听地址 : {args.host}:{args.port}')
+ print(f' 默认通道数: {cli_cfg["channels"]}')
+ print(f' 默认频率 : {actual_freq} Hz')
+ print(f' 默认采样深: {cli_cfg["depth"]} pts')
+ print(f' ─────────────────────────────────────────')
+ print(f' 在 App 连接界面将 IP 改为本机局域网 IP')
+ print(f' Android 模拟器请填 10.0.2.2')
+ print(f' Ctrl+C 停止\n')
+
+ try:
+ while True:
+ conn, addr = srv.accept()
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ session = Session(conn, addr, cli_cfg)
+ t = threading.Thread(target=session.run, daemon=True)
+ t.start()
+ except KeyboardInterrupt:
+ print('\n模拟器已停止')
+ finally:
+ srv.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/mock_device_gui.py b/tools/mock_device_gui.py
new file mode 100644
index 0000000..35d938a
--- /dev/null
+++ b/tools/mock_device_gui.py
@@ -0,0 +1,875 @@
+#!/usr/bin/env python3
+"""
+TEM 下位机模拟器 (GUI 版)
+
+依赖: Python 3.8+ 标准库 (tkinter, socket, threading, struct, math, random)
+用法: python tools/mock_device_gui.py [--port 4321]
+"""
+
+import tkinter as tk
+from tkinter import ttk, scrolledtext
+import socket
+import struct
+import threading
+import time
+import math
+import random
+import argparse
+from datetime import datetime
+from typing import Optional
+
+# ── 协议常量 ──────────────────────────────────────────────────────────────────
+
+MAGIC = b'\x68\x68\xff\xff'
+FRAME_FLAG = 0xFE
+
+class FC:
+ SETUP_REQ = 0x01
+ CONTINUOUS_REQ = 0x02
+ SINGLE_REQ = 0x03
+ STOP_REQ = 0x04
+ ACTIVE_REQ = 0x05
+ SPLITFRAME_REQ = 0x08
+ SETUP_ACK = 0x81
+ CONTINUOUS_ACK = 0x82
+ SINGLE_ACK = 0x83
+ STOP_ACK = 0x84
+ DATA_ACK = 0x85
+
+SEND_FREQ_OPTIONS = [
+ (0x00, '0.5 Hz'), (0x01, '1 Hz'), (0x02, '2 Hz'), (0x03, '4 Hz'),
+ (0x04, '8 Hz'), (0x05, '12.5 Hz'), (0x06, '16 Hz'), (0x07, '25 Hz'),
+ (0x08, '32 Hz'), (0x09, '50 Hz'), (0x0a, '64 Hz'),
+]
+SEND_FREQ_HZ = {c: float(lbl.split()[0]) for c, lbl in SEND_FREQ_OPTIONS}
+AMP_GAIN = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 1.0]
+AMP_LABELS = ['1/8×','1/4×','1/2×','1×','2×','4×','8×','16×','32×','64×','128×','自动']
+SIGNAL_TYPES = ['正弦衰减', '指数衰减', '正弦波', '阶跃响应', '纯噪声', '零信号']
+CH_COLORS = ['#e05050', '#44aa44', '#4466ff', '#dd9900', '#9944dd', '#00aacc']
+
+# ── 信号生成 ──────────────────────────────────────────────────────────────────
+
+_CH_DECAY = [0.5, 0.8, 1.2, 0.3, 1.8, 0.7]
+_CH_FREQ = [1.0, 2.0, 3.0, 5.0, 7.0, 11.0]
+
+def gen_signal(sig_type: int, ch: int, s: int, total: int, phase: float) -> float:
+ """归一化信号值 -1..1"""
+ if total <= 0:
+ return 0.0
+ t = s / total # 0..1 归一化时间
+ dk = _CH_DECAY[ch % len(_CH_DECAY)]
+ freq = _CH_FREQ [ch % len(_CH_FREQ)]
+
+ if sig_type == 0: # 正弦衰减
+ return math.exp(-t * dk * 8) * math.sin(2 * math.pi * (freq * t + phase * freq))
+ elif sig_type == 1: # 指数衰减
+ return math.exp(-t * dk * 8) * math.cos(2 * math.pi * phase * 0.3)
+ elif sig_type == 2: # 正弦
+ return math.sin(2 * math.pi * (freq * t + phase * freq))
+ elif sig_type == 3: # 阶跃响应
+ rise = 1.0 - math.exp(-t * 20)
+ return rise * math.exp(-t * dk * 5)
+ elif sig_type == 4: # 纯噪声
+ return random.gauss(0, 0.3)
+ else: # 零
+ return 0.0
+
+# ── 帧构建 ──────────────────────────────────────────────────────────────────
+
+def _pack_frame(func: int, payload: bytes) -> bytes:
+ return MAGIC + struct.pack(' bytes:
+ p = bytearray(54)
+ p[0] = 0x01 if ok else 0x02
+ return _pack_frame(func_ack, bytes(p))
+
+def build_data_frame(snap: dict, phase: float) -> bytes:
+ ch = snap['channelNum']
+ depth = snap['sampleDepth']
+ amp = snap['ampRatio']
+ acc = max(snap['accNum'], 1)
+ gain = AMP_GAIN[amp] if amp < len(AMP_GAIN) else 1.0
+ sig = snap['signalType']
+ sig_uv = snap['signalUV']
+ noise = snap['noiseRatio']
+
+ meta = bytearray(54)
+ meta[0] = 1
+ struct.pack_into(' 0 else 0
+ raw = int(v * sig_uv * gain * acc)
+ raw = max(-0x80000000, min(0x7FFFFFFF, raw))
+ struct.pack_into('= 10:
+ idx = next((i for i in range(len(self.buf) - 3)
+ if self.buf[i:i+4] == b'\x68\x68\xff\xff'), -1)
+ if idx == -1:
+ self.buf = bytearray(self.buf[-3:]) if len(self.buf) >= 3 else bytearray()
+ break
+ if idx > 0:
+ self.buf = self.buf[idx:]
+ if len(self.buf) < 10:
+ break
+ plen = struct.unpack_from(' 400 * 1024:
+ self.buf = self.buf[4:]
+ continue
+ total = 10 + plen
+ if len(self.buf) < total:
+ break
+ frames.append((self.buf[5], bytes(self.buf[10:total])))
+ self.buf = self.buf[total:]
+ return frames
+
+# ── 模拟配置(主线程写,采集线程读) ────────────────────────────────────────────
+
+class SimConfig:
+ def __init__(self):
+ self.channel_num = 6
+ self.send_freq = 0x01
+ self.sample_depth = 256
+ self.acc_num = 1
+ self.amp_ratio = 3
+ self.source_mode = 0
+ self.signal_type = 0
+ self.signal_uv = 5e5
+ self.noise_ratio = 0.02
+ self.lat = 39.9086
+ self.lon = 116.3972
+ self.alt = 50.0
+ self.gps_walk = False
+ self.gps_status = 1
+ self.sd_status = 1
+ self.roll = 0.0
+ self.pitch = 2.5
+ self.yaw = 0.0
+ self.batt_raw = 3760
+ self.temp_raw = 2100
+ self.batt_drain = False
+ self.frame_count = 0
+ self.bytes_sent = 0
+
+ def snapshot(self) -> dict:
+ return {
+ 'channelNum': self.channel_num, 'sendFreq': self.send_freq,
+ 'sampleDepth': self.sample_depth, 'accNum': self.acc_num,
+ 'ampRatio': self.amp_ratio, 'sourceMode': self.source_mode,
+ 'signalType': self.signal_type, 'signalUV': self.signal_uv,
+ 'noiseRatio': self.noise_ratio,
+ 'latitude': self.lat, 'longitude': self.lon, 'altitude': self.alt,
+ 'gpsStatus': self.gps_status, 'sdStatus': self.sd_status,
+ 'roll': self.roll, 'pitch': self.pitch, 'yaw': self.yaw,
+ 'tempRaw': self.temp_raw, 'battRaw': self.batt_raw,
+ }
+
+# ── 客户端会话 ─────────────────────────────────────────────────────────────────
+
+class Session:
+ def __init__(self, sock: socket.socket, addr, cfg: SimConfig, on_event):
+ self.sock = sock
+ self.addr = addr
+ self.cfg = cfg
+ self.on_event = on_event
+ self.parser = FrameParser()
+ self.running = False
+ self.phase = 0.0
+
+ def _ev(self, kind: str, data=None):
+ self.on_event(kind, data)
+
+ def _log(self, msg: str, tag: str = 'info'):
+ self._ev('log', (tag, f'{self.addr[0]}:{self.addr[1]} | {msg}'))
+
+ def _send(self, data: bytes) -> bool:
+ try:
+ self.sock.sendall(data)
+ self.cfg.bytes_sent += len(data)
+ return True
+ except OSError:
+ return False
+
+ # ── 连续发送线程 ──
+
+ def _data_loop(self):
+ self._log('开始连续推送', 'send')
+ while self.running:
+ t0 = time.monotonic()
+ snap = self.cfg.snapshot()
+ hz = SEND_FREQ_HZ.get(snap['sendFreq'], 1.0)
+ ivl = 1.0 / hz
+
+ frame = build_data_frame(snap, self.phase)
+ if not self._send(frame):
+ break
+
+ self.cfg.frame_count += 1
+ self.phase += ivl
+
+ if self.cfg.gps_walk:
+ self.cfg.lon += random.gauss(0, 5e-6)
+ self.cfg.lat += random.gauss(0, 3e-6)
+ if self.cfg.batt_drain and self.cfg.batt_raw > 2800:
+ if self.cfg.frame_count % 20 == 0:
+ self.cfg.batt_raw -= 1
+
+ self._ev('frame', {'ch': snap['channelNum'], 'depth': snap['sampleDepth'],
+ 'bytes': len(frame) - 10, 'hz': hz})
+
+ elapsed = time.monotonic() - t0
+ if ivl - elapsed > 0.002:
+ time.sleep(ivl - elapsed)
+
+ self._log('连续推送已停止', 'info')
+ self._ev('acq_status', '已停止')
+
+ def start_continuous(self):
+ if self.running:
+ return
+ self.running = True
+ threading.Thread(target=self._data_loop, daemon=True).start()
+ self._ev('acq_status', '连续推送')
+
+ def stop_continuous(self):
+ self.running = False
+
+ # ── 帧处理 ──
+
+ def _parse_setup(self, payload: bytes):
+ if len(payload) < 8:
+ return
+ self.cfg.channel_num = max(1, min(6, payload[0]))
+ self.cfg.send_freq = payload[1]
+ depth = struct.unpack_from(' 0 else 256
+ acc_flags = struct.unpack_from('> 2) & 0x3FFF
+ self.cfg.acc_num = acc if acc > 0 else 1
+ self.cfg.amp_ratio = payload[7] if payload[7] < len(AMP_GAIN) else 3
+ if len(payload) > 13:
+ self.cfg.source_mode = payload[13]
+ hz = SEND_FREQ_HZ.get(self.cfg.send_freq, 1.0)
+ self._log(f'SETUP ch={self.cfg.channel_num} freq={hz}Hz '
+ f'depth={self.cfg.sample_depth} acc={self.cfg.acc_num} '
+ f'amp={AMP_LABELS[self.cfg.amp_ratio]}', 'recv')
+ self._ev('config_changed', None)
+
+ def handle(self, func: int, payload: bytes):
+ if func == FC.SETUP_REQ:
+ self._parse_setup(payload)
+ self._send(build_ack(FC.SETUP_ACK))
+ self._log('SETUP_ACK →', 'send')
+ elif func == FC.CONTINUOUS_REQ:
+ self._log('CONTINUOUS_REQ ←', 'recv')
+ self._send(build_ack(FC.CONTINUOUS_ACK))
+ self._log('CONTINUOUS_ACK →', 'send')
+ self.start_continuous()
+ elif func == FC.SINGLE_REQ:
+ self._log('SINGLE_REQ ←', 'recv')
+ self._send(build_ack(FC.SINGLE_ACK))
+ frame = build_data_frame(self.cfg.snapshot(), self.phase)
+ self._send(frame)
+ self.cfg.frame_count += 1
+ self.phase += 1.0
+ self._log(f'DATA_ACK (单次) {len(frame)-10}B →', 'send')
+ self._ev('acq_status', '单次完成')
+ elif func == FC.STOP_REQ:
+ self._log('STOP_REQ ←', 'recv')
+ self.stop_continuous()
+ self._send(build_ack(FC.STOP_ACK))
+ self._log('STOP_ACK →', 'send')
+ elif func == FC.ACTIVE_REQ:
+ pass
+ elif func == FC.SPLITFRAME_REQ:
+ self._log('SPLITFRAME_REQ (忽略) ←', 'recv')
+ else:
+ self._log(f'未知 0x{func:02X} ←', 'info')
+
+ def run(self):
+ self._log('已连接 ✓', 'info')
+ self._ev('connected', f'{self.addr[0]}:{self.addr[1]}')
+ try:
+ while True:
+ data = self.sock.recv(4096)
+ if not data:
+ break
+ for func, payload in self.parser.feed(data):
+ self.handle(func, payload)
+ except OSError:
+ pass
+ finally:
+ self.stop_continuous()
+ self.sock.close()
+ self._log('已断开 ✗', 'info')
+ self._ev('disconnected', f'{self.addr[0]}:{self.addr[1]}')
+
+# ── GUI 主窗口 ─────────────────────────────────────────────────────────────────
+
+class MockDeviceApp:
+ def __init__(self, root: tk.Tk, port: int = 4321):
+ self.root = root
+ self.cfg = SimConfig()
+ self.session: Optional[Session] = None
+ self._srv_sock: Optional[socket.socket] = None
+ self._port = port
+
+ root.title('TEM 下位机模拟器')
+ root.minsize(860, 560)
+
+ style = ttk.Style()
+ for theme in ('clam', 'alt', 'default'):
+ try:
+ style.theme_use(theme)
+ break
+ except tk.TclError:
+ pass
+
+ self._build_ui()
+ self._start_server()
+ root.after(600, self._refresh) # first redraw after window is sized
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # UI 构建
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _build_ui(self):
+ r = self.root
+
+ # ── 顶部状态栏
+ top = ttk.Frame(r, padding=(6, 3))
+ top.pack(fill='x', side='top')
+
+ self._sv_var = tk.StringVar(value='启动中…')
+ self._conn_var = tk.StringVar(value='未连接')
+ self._acq_var = tk.StringVar(value='已停止')
+
+ ttk.Label(top, text='监听:', foreground='gray').pack(side='left')
+ ttk.Label(top, textvariable=self._sv_var, foreground='#666').pack(side='left', padx=(2, 12))
+
+ ttk.Label(top, text='客户端:', foreground='gray').pack(side='left')
+ self._conn_lbl = ttk.Label(top, textvariable=self._conn_var, foreground='#999')
+ self._conn_lbl.pack(side='left', padx=(2, 12))
+
+ ttk.Label(top, text='采集:', foreground='gray').pack(side='left')
+ self._acq_lbl = ttk.Label(top, textvariable=self._acq_var, foreground='#999')
+ self._acq_lbl.pack(side='left', padx=(2, 0))
+
+ ttk.Separator(r, orient='horizontal').pack(fill='x')
+
+ # ── 主体
+ body = tk.Frame(r)
+ body.pack(fill='both', expand=True, padx=6, pady=4)
+
+ # Left panel (fixed width)
+ left = ttk.Frame(body, width=270)
+ left.pack(side='left', fill='y', padx=(0, 6))
+ left.pack_propagate(False)
+
+ # Right panel
+ right = ttk.Frame(body)
+ right.pack(side='left', fill='both', expand=True)
+
+ self._build_signal_frame(left)
+ self._build_position_frame(left)
+ self._build_device_frame(left)
+ self._build_right(right)
+
+ # ── 底部统计栏
+ ttk.Separator(r, orient='horizontal').pack(fill='x')
+ bot = ttk.Frame(r, padding=(6, 2))
+ bot.pack(fill='x', side='bottom')
+ self._stat_var = tk.StringVar(value='帧数: 0 | 发送: 0 B')
+ ttk.Label(bot, textvariable=self._stat_var, foreground='gray',
+ font=('Consolas', 9)).pack(side='left')
+
+ ttk.Button(bot, text='重置统计', command=self._reset_stats,
+ width=8).pack(side='right')
+
+ def _build_signal_frame(self, parent):
+ f = ttk.LabelFrame(parent, text='信号配置', padding=6)
+ f.pack(fill='x', padx=2, pady=(0, 4))
+
+ def row(label, widget_factory, r):
+ ttk.Label(f, text=label).grid(row=r, column=0, sticky='w', pady=1)
+ w = widget_factory(f)
+ w.grid(row=r, column=1, sticky='we', padx=(4, 0), pady=1)
+ return w
+
+ # Signal type
+ self._sig_var = tk.StringVar(value=SIGNAL_TYPES[0])
+ cb = ttk.Combobox(f, textvariable=self._sig_var, values=SIGNAL_TYPES,
+ state='readonly', width=14)
+ ttk.Label(f, text='信号类型').grid(row=0, column=0, sticky='w', pady=1)
+ cb.grid(row=0, column=1, sticky='we', padx=(4, 0), pady=1)
+ cb.bind('<>', lambda _: self._apply_signal())
+
+ # Channel count
+ self._ch_var = tk.IntVar(value=6)
+ sp = ttk.Spinbox(f, from_=1, to=6, textvariable=self._ch_var, width=5,
+ command=self._apply_signal)
+ ttk.Label(f, text='通道数').grid(row=1, column=0, sticky='w', pady=1)
+ sp.grid(row=1, column=1, sticky='w', padx=(4, 0), pady=1)
+
+ # Send freq
+ self._freq_var = tk.StringVar(value='1 Hz')
+ cb2 = ttk.Combobox(f, textvariable=self._freq_var,
+ values=[lbl for _, lbl in SEND_FREQ_OPTIONS],
+ state='readonly', width=10)
+ ttk.Label(f, text='发送频率').grid(row=2, column=0, sticky='w', pady=1)
+ cb2.grid(row=2, column=1, sticky='w', padx=(4, 0), pady=1)
+ cb2.bind('<>', lambda _: self._apply_signal())
+
+ # Sample depth
+ self._depth_var = tk.IntVar(value=256)
+ ttk.Label(f, text='采样深度').grid(row=3, column=0, sticky='w', pady=1)
+ ttk.Spinbox(f, from_=16, to=8192, increment=16, textvariable=self._depth_var,
+ width=7, command=self._apply_signal).grid(
+ row=3, column=1, sticky='w', padx=(4, 0), pady=1)
+
+ # Amplitude
+ self._amp_uv_var = tk.StringVar(value='500000')
+ ttk.Label(f, text='幅度 (μV)').grid(row=4, column=0, sticky='w', pady=1)
+ ttk.Entry(f, textvariable=self._amp_uv_var, width=10).grid(
+ row=4, column=1, sticky='w', padx=(4, 0), pady=1)
+
+ # Noise %
+ self._noise_var = tk.StringVar(value='2')
+ ttk.Label(f, text='噪声 (%)').grid(row=5, column=0, sticky='w', pady=1)
+ ttk.Entry(f, textvariable=self._noise_var, width=6).grid(
+ row=5, column=1, sticky='w', padx=(4, 0), pady=1)
+
+ # Amp ratio
+ self._amp_ratio_var = tk.StringVar(value='1×')
+ cb3 = ttk.Combobox(f, textvariable=self._amp_ratio_var, values=AMP_LABELS,
+ state='readonly', width=8)
+ ttk.Label(f, text='增益倍率').grid(row=6, column=0, sticky='w', pady=1)
+ cb3.grid(row=6, column=1, sticky='w', padx=(4, 0), pady=1)
+ cb3.bind('<>', lambda _: self._apply_signal())
+
+ # Acc num
+ self._acc_var = tk.IntVar(value=1)
+ ttk.Label(f, text='叠加次数').grid(row=7, column=0, sticky='w', pady=1)
+ ttk.Spinbox(f, from_=1, to=512, textvariable=self._acc_var, width=6,
+ command=self._apply_signal).grid(
+ row=7, column=1, sticky='w', padx=(4, 0), pady=1)
+
+ ttk.Button(f, text='应用', command=self._apply_signal).grid(
+ row=8, column=1, sticky='e', pady=(4, 0))
+ f.columnconfigure(1, weight=1)
+
+ def _build_position_frame(self, parent):
+ f = ttk.LabelFrame(parent, text='位置与姿态', padding=6)
+ f.pack(fill='x', padx=2, pady=(0, 4))
+
+ self._lat_var = tk.StringVar(value='39.9086')
+ self._lon_var = tk.StringVar(value='116.3972')
+ self._alt_var = tk.StringVar(value='50.0')
+ self._pitch_var = tk.StringVar(value='2.5')
+ self._roll_var = tk.StringVar(value='0.0')
+
+ fields = [
+ ('纬度', self._lat_var), ('经度', self._lon_var),
+ ('海拔 (m)', self._alt_var), ('俯仰角 (°)', self._pitch_var),
+ ('侧倾角 (°)', self._roll_var),
+ ]
+ for r, (lbl, var) in enumerate(fields):
+ ttk.Label(f, text=lbl).grid(row=r, column=0, sticky='w', pady=1)
+ ttk.Entry(f, textvariable=var, width=12).grid(
+ row=r, column=1, sticky='we', padx=(4, 0), pady=1)
+
+ self._gps_walk_var = tk.BooleanVar(value=False)
+ ttk.Checkbutton(f, text='GPS 走点 (小幅随机漂移)',
+ variable=self._gps_walk_var,
+ command=self._apply_pos).grid(
+ row=len(fields), column=0, columnspan=2, sticky='w', pady=(4, 0))
+
+ ttk.Button(f, text='应用', command=self._apply_pos).grid(
+ row=len(fields) + 1, column=1, sticky='e', pady=(4, 0))
+ f.columnconfigure(1, weight=1)
+
+ def _build_device_frame(self, parent):
+ f = ttk.LabelFrame(parent, text='设备状态', padding=6)
+ f.pack(fill='x', padx=2, pady=(0, 4))
+
+ self._batt_var = tk.StringVar(value='3760')
+ self._temp_var = tk.StringVar(value='2100')
+ self._batt_drain_var = tk.BooleanVar(value=False)
+ self._gps_st_var = tk.BooleanVar(value=True)
+ self._sd_st_var = tk.BooleanVar(value=True)
+
+ ttk.Label(f, text='电池 raw').grid(row=0, column=0, sticky='w', pady=1)
+ ttk.Entry(f, textvariable=self._batt_var, width=8).grid(
+ row=0, column=1, sticky='w', padx=(4, 0))
+ ttk.Checkbutton(f, text='模拟耗电', variable=self._batt_drain_var,
+ command=self._apply_dev).grid(row=0, column=2, sticky='w', padx=4)
+
+ ttk.Label(f, text='温度 raw').grid(row=1, column=0, sticky='w', pady=1)
+ ttk.Entry(f, textvariable=self._temp_var, width=8).grid(
+ row=1, column=1, sticky='w', padx=(4, 0))
+
+ ttk.Checkbutton(f, text='GPS 有效', variable=self._gps_st_var,
+ command=self._apply_dev).grid(row=2, column=0, sticky='w', pady=(4,0))
+ ttk.Checkbutton(f, text='SD 就绪', variable=self._sd_st_var,
+ command=self._apply_dev).grid(row=2, column=1, sticky='w', pady=(4,0))
+
+ btn_row = ttk.Frame(f)
+ btn_row.grid(row=3, column=0, columnspan=3, sticky='e', pady=(4, 0))
+ ttk.Button(btn_row, text='充满电', width=7,
+ command=lambda: (self._batt_var.set('4200'),
+ setattr(self.cfg, 'batt_raw', 4200))).pack(side='left', padx=2)
+ ttk.Button(btn_row, text='应用', width=7,
+ command=self._apply_dev).pack(side='left')
+
+ def _build_right(self, parent):
+ # ── 波形预览
+ wf = ttk.LabelFrame(parent, text='波形预览', padding=4)
+ wf.pack(fill='x', padx=2, pady=(0, 4))
+
+ self._wc = tk.Canvas(wf, height=140, bg='#0a0a14', highlightthickness=1,
+ highlightbackground='#222')
+ self._wc.pack(fill='x')
+ self._wc.bind('', lambda _: self._redraw_waveform())
+
+ # ── 通信日志
+ lf = ttk.LabelFrame(parent, text='通信日志', padding=4)
+ lf.pack(fill='both', expand=True, padx=2)
+
+ self._log = scrolledtext.ScrolledText(
+ lf, height=12, font=('Consolas', 9), bg='#080810', fg='#aaaacc',
+ insertbackground='white', state='disabled', wrap='none',
+ )
+ self._log.pack(fill='both', expand=True)
+ self._log.tag_config('recv', foreground='#88bbff')
+ self._log.tag_config('send', foreground='#88ffaa')
+ self._log.tag_config('info', foreground='#dddd88')
+ self._log.tag_config('error', foreground='#ff7777')
+
+ ctrl = ttk.Frame(lf)
+ ctrl.pack(fill='x', pady=(2, 0))
+ ttk.Button(ctrl, text='清空日志', command=self._clear_log, width=8).pack(side='right')
+ ttk.Button(ctrl, text='注入帧', command=self._inject_frame, width=8).pack(side='right', padx=4)
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 服务器
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _start_server(self):
+ try:
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind(('0.0.0.0', self._port))
+ srv.listen(3)
+ self._srv_sock = srv
+ except OSError as e:
+ self._sv_var.set(f'启动失败: {e}')
+ return
+
+ # Detect LAN IP
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(('8.8.8.8', 80))
+ lan = s.getsockname()[0]
+ s.close()
+ except Exception:
+ lan = '127.0.0.1'
+
+ self._sv_var.set(
+ f'0.0.0.0:{self._port} 局域网: {lan}:{self._port}'
+ f' Android模拟器: 10.0.2.2:{self._port}'
+ )
+ threading.Thread(target=self._accept_loop, daemon=True).start()
+ self._log_line('服务器已启动', 'info')
+
+ def _accept_loop(self):
+ while self._srv_sock:
+ try:
+ conn, addr = self._srv_sock.accept()
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ except OSError:
+ break
+ sess = Session(conn, addr, self.cfg, self._on_event)
+ self.session = sess
+ threading.Thread(target=sess.run, daemon=True).start()
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 事件处理(后台线程 → root.after → 主线程)
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _on_event(self, kind: str, data):
+ self.root.after(0, lambda k=kind, d=data: self._handle(k, d))
+
+ def _handle(self, kind: str, data):
+ if kind == 'log':
+ tag, msg = data
+ self._log_line(msg, tag)
+ elif kind == 'connected':
+ self._conn_var.set(f'已连接: {data}')
+ self._conn_lbl.configure(foreground='#44cc44')
+ elif kind == 'disconnected':
+ self._conn_var.set('未连接')
+ self._conn_lbl.configure(foreground='#999')
+ self._acq_var.set('已停止')
+ self._acq_lbl.configure(foreground='#999')
+ elif kind == 'acq_status':
+ colors = {'连续推送': '#44cc44', '已停止': '#999', '单次完成': '#ddaa44'}
+ self._acq_var.set(data)
+ self._acq_lbl.configure(foreground=colors.get(data, '#999'))
+ elif kind == 'config_changed':
+ self._sync_ui_from_cfg()
+ elif kind == 'frame':
+ pass # stats updated via refresh timer
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 控件回调
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _apply_signal(self, *_):
+ try:
+ self.cfg.channel_num = max(1, min(6, self._ch_var.get()))
+ self.cfg.sample_depth = max(16, self._depth_var.get())
+ self.cfg.acc_num = max(1, self._acc_var.get())
+ self.cfg.signal_uv = float(self._amp_uv_var.get())
+ self.cfg.noise_ratio = float(self._noise_var.get()) / 100.0
+ except (ValueError, tk.TclError):
+ pass
+
+ sig_name = self._sig_var.get()
+ if sig_name in SIGNAL_TYPES:
+ self.cfg.signal_type = SIGNAL_TYPES.index(sig_name)
+
+ freq_lbl = self._freq_var.get()
+ for code, lbl in SEND_FREQ_OPTIONS:
+ if lbl == freq_lbl:
+ self.cfg.send_freq = code
+ break
+
+ amp_lbl = self._amp_ratio_var.get()
+ if amp_lbl in AMP_LABELS:
+ self.cfg.amp_ratio = AMP_LABELS.index(amp_lbl)
+
+ self._redraw_waveform()
+
+ def _apply_pos(self, *_):
+ try:
+ self.cfg.lat = float(self._lat_var.get())
+ self.cfg.lon = float(self._lon_var.get())
+ self.cfg.alt = float(self._alt_var.get())
+ self.cfg.pitch = float(self._pitch_var.get())
+ self.cfg.roll = float(self._roll_var.get())
+ except ValueError:
+ pass
+ self.cfg.gps_walk = self._gps_walk_var.get()
+
+ def _apply_dev(self, *_):
+ try:
+ self.cfg.batt_raw = int(self._batt_var.get())
+ self.cfg.temp_raw = int(self._temp_var.get())
+ except ValueError:
+ pass
+ self.cfg.batt_drain = self._batt_drain_var.get()
+ self.cfg.gps_status = 1 if self._gps_st_var.get() else 0
+ self.cfg.sd_status = 1 if self._sd_st_var.get() else 0
+
+ def _inject_frame(self):
+ """手动注入一帧(不管当前是否在连续推送中)"""
+ if self.session and self.session.sock:
+ frame = build_data_frame(self.cfg.snapshot(), self.session.phase)
+ try:
+ self.session.sock.sendall(frame)
+ self.cfg.frame_count += 1
+ self.cfg.bytes_sent += len(frame)
+ self.session.phase += 1.0
+ self._log_line(f'注入帧 {len(frame)-10}B →', 'send')
+ except OSError as e:
+ self._log_line(f'注入失败: {e}', 'error')
+ else:
+ self._log_line('无连接,无法注入帧', 'error')
+
+ def _sync_ui_from_cfg(self):
+ """SETUP_REQ 后将配置同步回 UI 控件"""
+ self._ch_var.set(self.cfg.channel_num)
+ self._depth_var.set(self.cfg.sample_depth)
+ self._acc_var.set(self.cfg.acc_num)
+ if self.cfg.amp_ratio < len(AMP_LABELS):
+ self._amp_ratio_var.set(AMP_LABELS[self.cfg.amp_ratio])
+ freq_lbl = next((lbl for c, lbl in SEND_FREQ_OPTIONS if c == self.cfg.send_freq), '1 Hz')
+ self._freq_var.set(freq_lbl)
+
+ def _reset_stats(self):
+ self.cfg.frame_count = 0
+ self.cfg.bytes_sent = 0
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 日志
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _log_line(self, msg: str, tag: str = ''):
+ ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
+ line = f'[{ts}] {msg}\n'
+ self._log.configure(state='normal')
+ self._log.insert('end', line, tag or '')
+ self._log.see('end')
+ # 保留最近 600 行
+ n = int(self._log.index('end-1c').split('.')[0])
+ if n > 600:
+ self._log.delete('1.0', f'{n - 600}.0')
+ self._log.configure(state='disabled')
+
+ def _clear_log(self):
+ self._log.configure(state='normal')
+ self._log.delete('1.0', 'end')
+ self._log.configure(state='disabled')
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 波形预览
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _redraw_waveform(self):
+ c = self._wc
+ c.delete('all')
+ W = c.winfo_width()
+ H = c.winfo_height()
+ if W < 20 or H < 20:
+ return
+
+ PL, PR, PT, PB = 6, 8, 8, 22
+ pw = W - PL - PR
+ ph = H - PT - PB
+
+ # Background fill
+ c.create_rectangle(PL, PT, PL + pw, PT + ph, fill='#0a0a14', outline='#1a1a28')
+
+ # Horizontal grid
+ for i in range(5):
+ y = PT + i * ph // 4
+ c.create_line(PL, y, PL + pw, y, fill='#15152a', dash=(3, 4))
+
+ # Zero line
+ y0 = PT + ph // 2
+ c.create_line(PL, y0, PL + pw, y0, fill='#22224a')
+
+ # Draw each channel curve
+ N = 300
+ n_ch = min(self.cfg.channel_num, 6)
+ sig = self.cfg.signal_type
+ phase = 0.0
+
+ for ch in range(n_ch):
+ pts = []
+ for i in range(N):
+ v = gen_signal(sig, ch, i, N, phase)
+ v = max(-1.0, min(1.0, v))
+ x = PL + i * pw // (N - 1)
+ y = PT + ph // 2 - int(v * ph * 0.44)
+ y = max(PT, min(PT + ph, y))
+ pts.extend([x, y])
+ if len(pts) >= 4:
+ c.create_line(*pts, fill=CH_COLORS[ch], width=1, smooth=False)
+
+ # Axes
+ c.create_line(PL, PT, PL, PT + ph, fill='#334', width=1)
+ c.create_line(PL, PT + ph, PL + pw, PT + ph, fill='#334', width=1)
+
+ # Channel legend (bottom)
+ for ch in range(n_ch):
+ lx = PL + ch * 44
+ c.create_rectangle(lx, PT + ph + 5, lx + 10, PT + ph + 12,
+ fill=CH_COLORS[ch], outline='')
+ c.create_text(lx + 13, PT + ph + 9, text=f'CH{ch+1}',
+ fill=CH_COLORS[ch], anchor='w', font=('Arial', 7))
+
+ # Signal type badge
+ c.create_text(PL + pw - 2, PT + 3,
+ text=SIGNAL_TYPES[sig], fill='#555577',
+ anchor='ne', font=('Arial', 8))
+
+ # Amplitude label
+ amp_str = f'{self.cfg.signal_uv/1000:.0f} kμV' if self.cfg.signal_uv >= 1000 else f'{self.cfg.signal_uv:.0f} μV'
+ c.create_text(PL + 2, PT + 3, text=amp_str, fill='#444466',
+ anchor='nw', font=('Arial', 7))
+
+ # ──────────────────────────────────────────────────────────────────────────
+ # 定时刷新(主线程,500ms 周期)
+ # ──────────────────────────────────────────────────────────────────────────
+
+ def _refresh(self):
+ # Sync batt / GPS widgets if auto-changing
+ if self.cfg.batt_drain:
+ self._batt_var.set(str(self.cfg.batt_raw))
+ if self.cfg.gps_walk:
+ self._lat_var.set(f'{self.cfg.lat:.6f}')
+ self._lon_var.set(f'{self.cfg.lon:.6f}')
+
+ batt_v = self.cfg.batt_raw / 1000.0
+ temp_c = (self.cfg.temp_raw - 1820) / 11.0 # rough raw→°C
+ hz_str = next((lbl for c, lbl in SEND_FREQ_OPTIONS if c == self.cfg.send_freq), '?')
+ self._stat_var.set(
+ f'帧数: {self.cfg.frame_count:,} | '
+ f'发送: {self.cfg.bytes_sent / 1024:.1f} KB | '
+ f'电池: {batt_v:.3f} V | '
+ f'温度: {temp_c:.1f} °C | '
+ f'频率: {hz_str}'
+ )
+
+ self._redraw_waveform()
+ self.root.after(500, self._refresh)
+
+
+# ── 入口 ──────────────────────────────────────────────────────────────────────
+
+def main():
+ ap = argparse.ArgumentParser(description='TEM 下位机模拟器 (GUI 版)')
+ ap.add_argument('--port', type=int, default=4321, help='监听端口 (默认: 4321)')
+ args = ap.parse_args()
+
+ root = tk.Tk()
+ app = MockDeviceApp(root, port=args.port)
+
+ def _on_close():
+ if app._srv_sock:
+ try:
+ app._srv_sock.close()
+ except Exception:
+ pass
+ if app.session:
+ app.session.stop_continuous()
+ root.destroy()
+
+ root.protocol('WM_DELETE_WINDOW', _on_close)
+ root.mainloop()
+
+
+if __name__ == '__main__':
+ main()