From 84590d54775272bc72872b6f3c91293edaf80a66 Mon Sep 17 00:00:00 2001 From: zhoujie <929834232@qq.com> Date: Fri, 19 Jun 2026 22:08:10 +0800 Subject: [PATCH] v0.1 --- .gitignore | 4 + app/(tabs)/_layout.tsx | 112 +-- app/(tabs)/control.tsx | 219 ++++-- app/(tabs)/index.tsx | 32 +- app/(tabs)/map.tsx | 12 +- app/(tabs)/profile.tsx | 698 +++++++++++++++++ app/(tabs)/projects.tsx | 545 ++++++++++++++ app/(tabs)/records.tsx | 374 ++++++--- app/(tabs)/two.tsx | 31 - app/(tabs)/wave.tsx | 558 ++++++++++++++ app/(tabs)/waveform.tsx | 106 +-- app/_layout.tsx | 13 +- app/connect.tsx | 326 ++++---- package-lock.json | 17 + package.json | 2 + run-android.bat | 23 + run-android.ps1 | 26 + src/components/ChannelSelector.tsx | 51 +- src/components/DeviceStatusBar.tsx | 83 +- src/components/ParamForm.tsx | 307 ++++---- src/components/WaveformChart.tsx | 256 ++++--- src/components/device/GlobalStatusBar.tsx | 120 +++ src/components/modals/ConnectModal.tsx | 277 +++++++ src/design/tokens.ts | 33 + src/hooks/useDevice.ts | 13 +- src/hooks/useWaveform.ts | 138 ++-- src/protocol/types.ts | 19 +- src/services/BinLoader.ts | 203 +++++ src/services/DeviceService.ts | 29 +- src/services/StorageService.ts | 497 ++++++++++++ src/services/TemBundle.ts | 101 +++ src/stores/connectionStore.ts | 53 +- src/stores/dataStore.ts | 115 ++- src/stores/deviceStore.ts | 82 +- src/utils/storage.ts | 32 + tools/mock_device.py | 370 +++++++++ tools/mock_device_gui.py | 875 ++++++++++++++++++++++ 37 files changed, 5848 insertions(+), 904 deletions(-) create mode 100644 app/(tabs)/profile.tsx create mode 100644 app/(tabs)/projects.tsx delete mode 100644 app/(tabs)/two.tsx create mode 100644 app/(tabs)/wave.tsx create mode 100644 run-android.bat create mode 100644 run-android.ps1 create mode 100644 src/components/device/GlobalStatusBar.tsx create mode 100644 src/components/modals/ConnectModal.tsx create mode 100644 src/design/tokens.ts create mode 100644 src/services/BinLoader.ts create mode 100644 src/services/StorageService.ts create mode 100644 src/services/TemBundle.ts create mode 100644 src/utils/storage.ts create mode 100644 tools/mock_device.py create mode 100644 tools/mock_device_gui.py 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 ( + + {wavePath && } + {gatePositions.map((pos, gi) => ( + + ))} + + ); +} + +// ── 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; + return ; + })} + {xTicks.map((i, idx) => ( + + ))} + {!logScale && isFinite(toY(0)) && ( + + )} + + + {paths.map((p, gi) => + p ? : null, + )} + + {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 ( - + - {/* Background */} - {/* Y-axis grid lines */} - {yGridLines.map((v) => { + {/* Y grid lines */} + {yGridValues.map((v) => { const y = toY(v); + if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; return ( - + ); })} - {/* X-axis baseline */} - + {/* X grid lines */} + {xTickValues.map((ms) => { + const x = toXms(ms); + return ( + + ); + })} - {/* Channel waveform paths */} + {/* Baseline */} + {!logScale && ( + + )} + + {/* X axis */} + + + {/* Channel paths */} {channelPaths.map((path, idx) => { if (!path || !visibleChannels[idx]) return null; return ( - + style="stroke" strokeWidth={1.5} + strokeJoin="round" strokeCap="round" /> ); })} - {/* 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()