v0.1
This commit is contained in:
parent
091b358705
commit
84590d5477
4
.gitignore
vendored
4
.gitignore
vendored
@ -39,3 +39,7 @@ yarn-error.*
|
|||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
# python
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@ -1,63 +1,73 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Platform } from 'react-native';
|
import { Platform, View } from 'react-native';
|
||||||
import { SymbolView } from 'expo-symbols';
|
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 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({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
|
||||||
function TabIcon({ name, color }: { name: string; color: any }) {
|
if (Platform.OS === 'ios') {
|
||||||
return <SymbolView name={name as any} tintColor={color} size={22} />;
|
return <SymbolView name={ios as any} tintColor={color} size={22} />;
|
||||||
|
}
|
||||||
|
return <Text style={{ fontSize: 18, color, lineHeight: 24 }}>{emoji}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: Colors.bg.base, paddingTop: insets.top }}>
|
||||||
|
<GlobalStatusBar />
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
|
tabBarStyle: { backgroundColor: TAB_BG, borderTopColor: '#222' },
|
||||||
tabBarActiveTintColor: ACTIVE,
|
tabBarActiveTintColor: ACTIVE,
|
||||||
tabBarInactiveTintColor: INACTIVE,
|
tabBarInactiveTintColor: INACTIVE,
|
||||||
tabBarLabelStyle: { fontSize: 10 },
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Hidden screens */}
|
||||||
|
<Tabs.Screen name="index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="control" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="waveform" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="map" options={{ href: null }} />
|
||||||
|
|
||||||
|
{/* Visible tabs */}
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="control"
|
name="wave"
|
||||||
options={{
|
|
||||||
title: '控制台',
|
|
||||||
tabBarIcon: ({ color }) => (
|
|
||||||
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="waveform"
|
|
||||||
options={{
|
options={{
|
||||||
title: '波形',
|
title: '波形',
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => <TabIcon ios="waveform" emoji="〜" color={color} />,
|
||||||
<TabIcon name={Platform.select({ ios: 'waveform', android: 'show_chart', web: 'show_chart' })!} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="map"
|
|
||||||
options={{
|
|
||||||
title: '地图',
|
|
||||||
tabBarIcon: ({ color }) => (
|
|
||||||
<TabIcon name={Platform.select({ ios: 'map', android: 'map', web: 'map' })!} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="records"
|
name="records"
|
||||||
options={{
|
options={{
|
||||||
title: '记录',
|
title: '测点',
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => <TabIcon ios="list.bullet" emoji="≡" color={color} />,
|
||||||
<TabIcon name={Platform.select({ ios: 'list.bullet', android: 'list', web: 'list' })!} color={color} />
|
}}
|
||||||
),
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: '剖面',
|
||||||
|
tabBarIcon: ({ color }) => <TabIcon ios="chart.line.uptrend.xyaxis" emoji="↗" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="projects"
|
||||||
|
options={{
|
||||||
|
title: '工程',
|
||||||
|
tabBarIcon: ({ color }) => <TabIcon ios="folder" emoji="▤" color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,27 @@
|
|||||||
import React, { useState } from 'react';
|
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 { router } from 'expo-router';
|
||||||
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||||
import { ParamForm } from '../../src/components/ParamForm';
|
import { ParamForm } from '../../src/components/ParamForm';
|
||||||
import { useDevice } from '../../src/hooks/useDevice';
|
import { useDevice } from '../../src/hooks/useDevice';
|
||||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
|
||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
|
|
||||||
export default function ControlScreen() {
|
export default function ControlScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
|
const sessionId = useDataStore((s) => s.sessionId);
|
||||||
|
const projectId = useDataStore((s) => s.projectId);
|
||||||
const [configDirty, setConfigDirty] = useState(false);
|
const [configDirty, setConfigDirty] = useState(false);
|
||||||
|
const [projectName, setProjectName] = React.useState<string | null>(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 () => {
|
const handleSetup = async () => {
|
||||||
await setup();
|
await setup();
|
||||||
@ -19,132 +30,190 @@ export default function ControlScreen() {
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.notConnected}>
|
<View style={[S.notConnected, { paddingTop: insets.top }]}>
|
||||||
<Text style={styles.notConnectedText}>未连接设备</Text>
|
<Text style={S.notConnectedIcon}>⊘</Text>
|
||||||
<TouchableOpacity style={styles.connectBtn} onPress={() => router.replace('/connect')}>
|
<Text style={S.notConnectedText}>未连接设备</Text>
|
||||||
<Text style={styles.connectBtnText}>前往连接</Text>
|
<TouchableOpacity style={S.goConnectBtn} onPress={() => router.push('/connect')} activeOpacity={0.8}>
|
||||||
|
<Text style={S.goConnectBtnText}>前往连接</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = deviceStatus === 'running';
|
const running = deviceStatus === 'running';
|
||||||
const canStart = !running && !busy;
|
const single = deviceStatus === 'single';
|
||||||
const canStop = (running || deviceStatus === 'single') && !busy;
|
const canStart = !running && !single && !busy;
|
||||||
|
const canStop = (running || single) && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[S.container, { paddingTop: insets.top }]}>
|
||||||
<DeviceStatusBar />
|
<DeviceStatusBar />
|
||||||
|
{/* Project / line context */}
|
||||||
|
<View style={S.contextBar}>
|
||||||
|
{projectName
|
||||||
|
? <Text style={S.contextText} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||||
|
: <Text style={S.contextText} numberOfLines={1}>{sessionId}</Text>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
<View style={styles.ctrlRow}>
|
<View style={S.ctrlRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
|
style={[S.ctrlBtn, S.btnStart, !canStart && S.btnDisabled]}
|
||||||
onPress={startContinuous}
|
onPress={startContinuous}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}>连续采集</Text>}
|
{busy && !canStop
|
||||||
|
? <ActivityIndicator color="#3ddc84" size="small" />
|
||||||
|
: <>
|
||||||
|
<Text style={S.ctrlIcon}>▶</Text>
|
||||||
|
<Text style={[S.ctrlText, S.ctrlTextGreen]}>连续采集</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
|
style={[S.ctrlBtn, S.btnSingle, !canStart && S.btnDisabled]}
|
||||||
onPress={startSingle}
|
onPress={startSingle}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.ctrlText}>单次</Text>
|
<Text style={S.ctrlIcon}>◎</Text>
|
||||||
|
<Text style={[S.ctrlText, S.ctrlTextBlue]}>单 次</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
|
style={[S.ctrlBtn, S.btnStop, !canStop && S.btnDisabled]}
|
||||||
onPress={stop}
|
onPress={stop}
|
||||||
disabled={!canStop}
|
disabled={!canStop}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.ctrlText}>停止</Text>
|
{busy && canStop
|
||||||
|
? <ActivityIndicator color="#ff5c6e" size="small" />
|
||||||
|
: <>
|
||||||
|
<Text style={S.ctrlIcon}>■</Text>
|
||||||
|
<Text style={[S.ctrlText, S.ctrlTextRed]}>停 止</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Last frame info */}
|
{/* Frame info */}
|
||||||
{frame && (
|
{frame && (
|
||||||
<View style={styles.frameInfo}>
|
<View style={S.frameBar}>
|
||||||
<Text style={styles.frameText}>
|
<View style={S.frameItem}>
|
||||||
帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
|
<Text style={S.frameItemLabel}>帧号</Text>
|
||||||
</Text>
|
<Text style={S.frameItemValue}>#{frame.frameId}</Text>
|
||||||
<Text style={styles.frameText}>
|
</View>
|
||||||
电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
|
<View style={S.frameSep} />
|
||||||
</Text>
|
<View style={S.frameItem}>
|
||||||
|
<Text style={S.frameItemLabel}>叠加</Text>
|
||||||
|
<Text style={S.frameItemValue}>{frame.accNum}次</Text>
|
||||||
|
</View>
|
||||||
|
<View style={S.frameSep} />
|
||||||
|
<View style={S.frameItem}>
|
||||||
|
<Text style={S.frameItemLabel}>通道</Text>
|
||||||
|
<Text style={S.frameItemValue}>{frame.meta?.channelNum ?? '-'}ch</Text>
|
||||||
|
</View>
|
||||||
|
<View style={S.frameSep} />
|
||||||
|
<View style={S.frameItem}>
|
||||||
|
<Text style={S.frameItemLabel}>电流</Text>
|
||||||
|
<Text style={S.frameItemValue}>{frame.meta?.current ?? '--'}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Param form */}
|
{/* Param form */}
|
||||||
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
|
<ScrollView style={S.formScroll} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.formHeader}>
|
<View style={S.formHeader}>
|
||||||
<Text style={styles.formTitle}>采集参数</Text>
|
<Text style={S.formTitle}>采集参数</Text>
|
||||||
{configDirty && <Text style={styles.dirtyBadge}>未下发</Text>}
|
{configDirty && (
|
||||||
|
<View style={S.dirtyBadge}>
|
||||||
|
<Text style={S.dirtyBadgeText}>待下发</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.setupBtn, busy && styles.btnDisabled]}
|
style={[S.setupBtn, busy && S.btnDisabled]}
|
||||||
onPress={handleSetup}
|
onPress={handleSetup}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}>下 发 配 置</Text>}
|
{busy
|
||||||
|
? <ActivityIndicator color="#4a9eff" />
|
||||||
|
: <Text style={S.setupBtnText}>下 发 配 置</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 32 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
container: { flex: 1, backgroundColor: '#090912' },
|
||||||
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
|
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, backgroundColor: '#090912' },
|
||||||
notConnectedText: { color: '#666', fontSize: 16 },
|
notConnectedIcon: { fontSize: 48, color: '#2a2a4a' },
|
||||||
connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
|
notConnectedText: { color: '#4a4a6a', fontSize: 15 },
|
||||||
connectBtnText: { color: '#fff', fontWeight: '600' },
|
goConnectBtn: { backgroundColor: '#111120', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1, borderColor: '#4a9eff' },
|
||||||
ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
|
goConnectBtnText: { color: '#4a9eff', fontWeight: '600' },
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||||
ctrlBtn: {
|
ctrlBtn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
borderRadius: 8,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
btnStart: { backgroundColor: '#1a5c2a' },
|
btnStart: { backgroundColor: '#0d2018', borderColor: '#1a4a28' },
|
||||||
btnSingle: { backgroundColor: '#1a3a5c' },
|
btnSingle: { backgroundColor: '#0d1a30', borderColor: '#1a2e54' },
|
||||||
btnStop: { backgroundColor: '#5c1a1a' },
|
btnStop: { backgroundColor: '#1e0d12', borderColor: '#3a1520' },
|
||||||
btnDisabled: { opacity: 0.4 },
|
btnDisabled: { opacity: 0.3 },
|
||||||
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
ctrlIcon: { fontSize: 12, color: '#ffffff88' },
|
||||||
frameInfo: {
|
ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
|
||||||
marginHorizontal: 12,
|
ctrlTextGreen: { color: '#3ddc84' },
|
||||||
backgroundColor: '#181818',
|
ctrlTextBlue: { color: '#4a9eff' },
|
||||||
borderRadius: 8,
|
ctrlTextRed: { color: '#ff5c6e' },
|
||||||
padding: 8,
|
|
||||||
marginBottom: 8,
|
// Frame info bar
|
||||||
},
|
frameBar: {
|
||||||
frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
|
|
||||||
formScroll: { flex: 1 },
|
|
||||||
formHeader: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
marginHorizontal: 12,
|
||||||
gap: 10,
|
marginBottom: 8,
|
||||||
paddingHorizontal: 14,
|
backgroundColor: '#0c0c1a',
|
||||||
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',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
paddingVertical: 12,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,31 +1,5 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
export default function TabIndex() {
|
||||||
import { Text, View } from '@/components/Themed';
|
return <Redirect href="/(tabs)/control" />;
|
||||||
|
|
||||||
export default function TabOneScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab One</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/index.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
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 MapView, { Marker, Polyline, type Region } from 'react-native-maps';
|
||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
import { formatCoord } from '../../src/utils/format';
|
import { formatCoord } from '../../src/utils/format';
|
||||||
|
|
||||||
export default function MapScreen() {
|
export default function MapScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const history = useDataStore((s) => s.history);
|
const history = useDataStore((s) => s.history);
|
||||||
const mapRef = useRef<MapView>(null);
|
const mapRef = useRef<MapView>(null);
|
||||||
|
|
||||||
@ -58,8 +60,8 @@ export default function MapScreen() {
|
|||||||
))}
|
))}
|
||||||
</MapView>
|
</MapView>
|
||||||
|
|
||||||
{/* Overlay info */}
|
{/* Overlay info — offset by safe area top */}
|
||||||
<View style={styles.overlay}>
|
<View style={[styles.overlay, { top: 12 + insets.top }]}>
|
||||||
<Text style={styles.overlayText}>测点数: {points.length}</Text>
|
<Text style={styles.overlayText}>测点数: {points.length}</Text>
|
||||||
{latest && (
|
{latest && (
|
||||||
<Text style={styles.overlayText}>
|
<Text style={styles.overlayText}>
|
||||||
@ -68,8 +70,8 @@ export default function MapScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Center button */}
|
{/* Center button — offset by safe area bottom */}
|
||||||
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
|
<TouchableOpacity style={[styles.centerBtn, { bottom: 24 + insets.bottom }]} onPress={centerOnCurrent}>
|
||||||
<Text style={styles.centerBtnText}>⌖</Text>
|
<Text style={styles.centerBtnText}>⌖</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@ -89,7 +91,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 12,
|
|
||||||
left: 12,
|
left: 12,
|
||||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -99,7 +100,6 @@ const styles = StyleSheet.create({
|
|||||||
overlayText: { color: '#ddd', fontSize: 11 },
|
overlayText: { color: '#ddd', fontSize: 11 },
|
||||||
centerBtn: {
|
centerBtn: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
|
||||||
right: 16,
|
right: 16,
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
|
|||||||
698
app/(tabs)/profile.tsx
Normal file
698
app/(tabs)/profile.tsx
Normal file
@ -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<number, number> = {
|
||||||
|
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<LoadedFrame[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
|
|
||||||
|
const liveFrames = useMemo<LoadedFrame[]>(() => {
|
||||||
|
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<GateSpacing>(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 (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<TouchableOpacity style={GSM.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
|
<View style={GSM.sheet}>
|
||||||
|
<Text style={GSM.title}>时间门配置</Text>
|
||||||
|
|
||||||
|
<View style={GSM.row}>
|
||||||
|
<View style={GSM.field}>
|
||||||
|
<Text style={GSM.label}>起始时间 (μs)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={GSM.input}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={tStartStr}
|
||||||
|
onChangeText={setTStartStr}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={GSM.field}>
|
||||||
|
<Text style={GSM.label}>终止时间 (μs)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={GSM.input}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={tEndStr}
|
||||||
|
onChangeText={setTEndStr}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[GSM.row, { marginTop: 6 }]}>
|
||||||
|
<View style={GSM.field}>
|
||||||
|
<Text style={GSM.label}>门数量</Text>
|
||||||
|
<TextInput
|
||||||
|
style={GSM.input}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={countStr}
|
||||||
|
onChangeText={setCountStr}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
|
||||||
|
<Text style={GSM.label}>间隔方式</Text>
|
||||||
|
<View style={GSM.toggle}>
|
||||||
|
{(['log', 'linear'] as GateSpacing[]).map(s => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s}
|
||||||
|
style={[GSM.toggleBtn, spacing === s && GSM.toggleBtnOn]}
|
||||||
|
onPress={() => setSpacing(s)}
|
||||||
|
>
|
||||||
|
<Text style={[GSM.toggleTxt, spacing === s && GSM.toggleTxtOn]}>
|
||||||
|
{s === 'log' ? '对数' : '线性'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{sampleDepth > 0 && hz > 0 && (
|
||||||
|
<Text style={GSM.hint}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={GSM.actions}>
|
||||||
|
<TouchableOpacity style={GSM.cancelBtn} onPress={onClose}>
|
||||||
|
<Text style={GSM.cancelTxt}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={GSM.applyBtn} onPress={handleApply}>
|
||||||
|
<Text style={GSM.applyTxt}>应用</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Canvas style={{ width, height: CANVAS_H }}>
|
||||||
|
{wavePath && <Path path={wavePath} color="#445566" style="stroke" strokeWidth={1} />}
|
||||||
|
{gatePositions.map((pos, gi) => (
|
||||||
|
<Line key={gi}
|
||||||
|
p1={vec(GP.l + pos * plotW, GP.t)}
|
||||||
|
p2={vec(GP.l + pos * plotW, GP.t + plotH)}
|
||||||
|
color={gateColors[gi] ?? '#fff'} strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<View style={{ width, height, position: 'relative' }}>
|
||||||
|
<Canvas style={{ width, height }}>
|
||||||
|
{yTicks.map((v, idx) => {
|
||||||
|
const y = toY(v);
|
||||||
|
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||||
|
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color="#222" strokeWidth={0.5} />;
|
||||||
|
})}
|
||||||
|
{xTicks.map((i, idx) => (
|
||||||
|
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color="#1e1e1e" strokeWidth={0.5} />
|
||||||
|
))}
|
||||||
|
{!logScale && isFinite(toY(0)) && (
|
||||||
|
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color="#444" strokeWidth={0.8} />
|
||||||
|
)}
|
||||||
|
<Line p1={vec(left, bot)} p2={vec(right, bot)} color="#555" strokeWidth={1} />
|
||||||
|
<Line p1={vec(left, top)} p2={vec(left, bot)} color="#555" strokeWidth={1} />
|
||||||
|
{paths.map((p, gi) =>
|
||||||
|
p ? <Path key={`g${gi}`} path={p} color={gateColors[gi] ?? '#fff'} style="stroke"
|
||||||
|
strokeWidth={1.5} strokeJoin="round" strokeCap="round" /> : null,
|
||||||
|
)}
|
||||||
|
</Canvas>
|
||||||
|
{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 <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4 }]}>{label}</Text>;
|
||||||
|
})}
|
||||||
|
{xTicks.map((i, idx) => (
|
||||||
|
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36 }]}>{i}</Text>
|
||||||
|
))}
|
||||||
|
<Text style={[LBL.y, { top: 2, left: 2 }]}>μV</Text>
|
||||||
|
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8 }]}>帧</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<TouchableOpacity style={SPM.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
|
<View style={SPM.sheet}>
|
||||||
|
<View style={SPM.header}>
|
||||||
|
<Text style={SPM.title}>选择测线</Text>
|
||||||
|
<TouchableOpacity style={[SPM.allBtn, showAll && SPM.allBtnOn]} onPress={() => setShowAll(v => !v)}>
|
||||||
|
<Text style={[SPM.allTxt, showAll && SPM.allTxtOn]}>{showAll ? '全部' : '当前工程'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={listData}
|
||||||
|
keyExtractor={item => item.sessionId}
|
||||||
|
style={SPM.list}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isCur = item.sessionId === currentId;
|
||||||
|
const isSel = item.sessionId === selectedId;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[SPM.item, isSel && SPM.itemSel]}
|
||||||
|
onPress={() => { onSelect(item.sessionId); onClose(); }}
|
||||||
|
>
|
||||||
|
<Text style={SPM.itemId} numberOfLines={1}>
|
||||||
|
{item.sessionId}{isCur ? ' (当前)' : ''}
|
||||||
|
</Text>
|
||||||
|
{item.frameCount >= 0 && (
|
||||||
|
<Text style={SPM.itemCnt}>{item.frameCount} 帧</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SessionInfo[]>([]);
|
||||||
|
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
|
||||||
|
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 (
|
||||||
|
<View style={SCR.root}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<View style={SCR.header}>
|
||||||
|
<TouchableOpacity style={SCR.sessBtn} onPress={() => setPickerVisible(true)}>
|
||||||
|
<Text style={SCR.sessBtnTxt} numberOfLines={1}>{sessionId}</Text>
|
||||||
|
<Text style={SCR.sessBtnArrow}>▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[SCR.scaleBtn, !logScale && SCR.scaleBtnOn]} onPress={() => setLogScale(v => !v)}>
|
||||||
|
<Text style={[SCR.scaleTxt, !logScale && SCR.scaleTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Channel selector ── */}
|
||||||
|
<View style={SCR.chRow}>
|
||||||
|
{Array.from({ length: maxCh }, (_, i) => (
|
||||||
|
<TouchableOpacity key={i}
|
||||||
|
style={[SCR.chBtn, channelIdx === i && {
|
||||||
|
borderColor: CHANNEL_COLORS[i] + '99',
|
||||||
|
backgroundColor: CHANNEL_COLORS[i] + '1a',
|
||||||
|
}]}
|
||||||
|
onPress={() => setChannelIdx(i)}
|
||||||
|
>
|
||||||
|
<Text style={[SCR.chTxt, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
|
||||||
|
CH{i + 1}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Loading progress ── */}
|
||||||
|
{loading && (
|
||||||
|
<View style={SCR.loadRow}>
|
||||||
|
<ActivityIndicator size="small" color="#4a9eff" />
|
||||||
|
<Text style={SCR.loadTxt}>加载 {progress.current}/{progress.total} 帧…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Empty ── */}
|
||||||
|
{!loading && frames.length === 0 && (
|
||||||
|
<View style={SCR.empty}>
|
||||||
|
<Text style={SCR.emptyTxt}>暂无剖面数据</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Charts ── */}
|
||||||
|
{frames.length > 0 && (
|
||||||
|
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
|
<GateLineChart
|
||||||
|
frames={frames} channelIdx={channelIdx}
|
||||||
|
gatePositions={gatePositions} gateColors={gateColors}
|
||||||
|
logScale={logScale} width={sw} height={CHART_H}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gate preview + legend + settings */}
|
||||||
|
<View style={SCR.gateSection}>
|
||||||
|
<View style={SCR.gateHeader}>
|
||||||
|
<Text style={SCR.gateHdr}>
|
||||||
|
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={SCR.settingsBtn} onPress={() => setSettingsVisible(true)}>
|
||||||
|
<Text style={SCR.settingsTxt}>⚙ 配置</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<GatePreview
|
||||||
|
refFrame={refFrame} channelIdx={channelIdx}
|
||||||
|
gatePositions={gatePositions} gateColors={gateColors}
|
||||||
|
width={sw}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={SCR.legendScroll}>
|
||||||
|
<View style={SCR.legendRow}>
|
||||||
|
{gatePositions.map((pos, gi) => {
|
||||||
|
const tUs = sampleDepth > 0 && hz > 0 ? pos * (sampleDepth / hz) * 1e6 : 0;
|
||||||
|
return (
|
||||||
|
<View key={gi} style={SCR.legendItem}>
|
||||||
|
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
|
||||||
|
<Text style={SCR.legendTxt}>G{gi + 1} {fmtUs(tUs)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SessionPickerModal
|
||||||
|
visible={pickerVisible}
|
||||||
|
sessions={projectSessions}
|
||||||
|
allSessions={allSessions}
|
||||||
|
currentId={currentSessionId}
|
||||||
|
selectedId={sessionId}
|
||||||
|
onSelect={setSessionId}
|
||||||
|
onClose={() => setPickerVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GateSettingsModal
|
||||||
|
visible={settingsVisible}
|
||||||
|
config={gateConfig}
|
||||||
|
onApply={setGateConfig}
|
||||||
|
onClose={() => setSettingsVisible(false)}
|
||||||
|
sampleDepth={sampleDepth}
|
||||||
|
hz={hz}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
545
app/(tabs)/projects.tsx
Normal file
545
app/(tabs)/projects.tsx
Normal file
@ -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 (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
|
<View style={NM.box}>
|
||||||
|
<Text style={NM.title}>{title}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={NM.input}
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#3a3a5a"
|
||||||
|
autoFocus
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
<View style={NM.row}>
|
||||||
|
<TouchableOpacity style={NM.cancelBtn} onPress={onClose}>
|
||||||
|
<Text style={NM.cancelTxt}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[NM.confirmBtn, !text.trim() && NM.btnDis]}
|
||||||
|
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
|
||||||
|
disabled={!text.trim()}
|
||||||
|
>
|
||||||
|
<Text style={NM.confirmTxt}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SessionInfo[]>([]);
|
||||||
|
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 <ActivityIndicator style={{ padding: 16 }} color="#4a9eff" size="small" />;
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return <Text style={LL.empty}>暂无测线</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={LL.wrap}>
|
||||||
|
{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 (
|
||||||
|
<View key={line.sessionId} style={[LL.row, isActive && LL.rowActive]}>
|
||||||
|
<View style={LL.rowLeft}>
|
||||||
|
{isActive && <View style={LL.activeDot} />}
|
||||||
|
<View>
|
||||||
|
<Text style={LL.lineId} numberOfLines={1}>{line.sessionId}</Text>
|
||||||
|
<Text style={LL.lineSub}>{dateStr} · {line.frameCount} 测点</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={LL.actions}>
|
||||||
|
{!isActive && (
|
||||||
|
<TouchableOpacity style={[LL.btn, LL.actBtn]} onPress={() => onActivate(line.sessionId)}>
|
||||||
|
<Text style={LL.actTxt}>激活</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={[LL.btn, LL.expBtn]} onPress={() => onExport(line.sessionId)}>
|
||||||
|
<Text style={LL.expTxt}>导出</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{!isActive && (
|
||||||
|
<TouchableOpacity style={[LL.btn, LL.delBtn]} onPress={() => onDelete(line.sessionId)}>
|
||||||
|
<Text style={LL.delTxt}>删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProjectInfo[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState<string | null>(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 (
|
||||||
|
<View style={[S.root, { paddingTop: insets.top }]}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={S.header}>
|
||||||
|
<Text style={S.headerTitle}>工程管理</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.importBtn, importing && S.btnDis]}
|
||||||
|
onPress={handleImportProject}
|
||||||
|
disabled={importing}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{importing
|
||||||
|
? <ActivityIndicator color="#ffd93d" size="small" />
|
||||||
|
: <Text style={S.importBtnTxt}>导入 .tem</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={S.addBtn} onPress={handleCreateProject} activeOpacity={0.8}>
|
||||||
|
<Text style={S.addBtnTxt}>+ 新建</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar (export / import) */}
|
||||||
|
{(importing || exporting !== null) && progress.total > 0 && (
|
||||||
|
<View style={S.progressBar}>
|
||||||
|
<View style={[S.progressFill, { width: `${Math.round(progress.current / progress.total * 100)}%` as any }]} />
|
||||||
|
<Text style={S.progressTxt}>{progress.current}/{progress.total} 帧</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current context pill */}
|
||||||
|
<View style={S.contextBar}>
|
||||||
|
<Text style={S.contextLabel}>当前测线</Text>
|
||||||
|
<Text style={S.contextSession} numberOfLines={1}>{currentSessionId}</Text>
|
||||||
|
{currentProjectId ? (
|
||||||
|
<View style={S.projectPill}>
|
||||||
|
<Text style={S.projectPillTxt}>
|
||||||
|
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[S.projectPill, S.projectPillNone]}>
|
||||||
|
<Text style={[S.projectPillTxt, S.projectPillTxtNone]}>未分配工程</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Project list */}
|
||||||
|
<FlatList
|
||||||
|
data={projects}
|
||||||
|
keyExtractor={(p) => p.projectId}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={S.empty}>
|
||||||
|
<Text style={S.emptyIcon}>◫</Text>
|
||||||
|
<Text style={S.emptyTxt}>暂无工程</Text>
|
||||||
|
<Text style={S.emptyHint}>点击右上角「新建工程」开始</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: project }) => {
|
||||||
|
const isExpanded = expanded === project.projectId;
|
||||||
|
const isCurrentProject = project.projectId === currentProjectId;
|
||||||
|
return (
|
||||||
|
<View style={S.projectCard}>
|
||||||
|
{/* Project row */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={S.projectRow}
|
||||||
|
onPress={() => setExpanded(isExpanded ? null : project.projectId)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={S.projectRowLeft}>
|
||||||
|
<Text style={S.chevron}>{isExpanded ? '▾' : '▸'}</Text>
|
||||||
|
<View>
|
||||||
|
<View style={S.projectNameRow}>
|
||||||
|
<Text style={S.projectName}>{project.name}</Text>
|
||||||
|
{isCurrentProject && (
|
||||||
|
<View style={S.activePill}>
|
||||||
|
<Text style={S.activePillTxt}>当前</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={S.projectMeta}>
|
||||||
|
{project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={S.projectActions}>
|
||||||
|
<TouchableOpacity style={[S.pBtn, S.pBtnNew]} onPress={() => handleOpenProject(project)}>
|
||||||
|
<Text style={S.pBtnNewTxt}>打开</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.pBtn, S.pBtnExp, exporting === project.projectId && S.btnDis]}
|
||||||
|
onPress={() => handleExportProject(project)}
|
||||||
|
disabled={exporting === project.projectId}
|
||||||
|
>
|
||||||
|
{exporting === project.projectId
|
||||||
|
? <ActivityIndicator color="#ffd93d" size="small" style={{ width: 28 }} />
|
||||||
|
: <Text style={S.pBtnExpTxt}>.tem</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={S.pBtn} onPress={() => handleRenameProject(project)}>
|
||||||
|
<Text style={S.pBtnTxt}>改名</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[S.pBtn, S.pBtnDel]} onPress={() => handleDeleteProject(project)}>
|
||||||
|
<Text style={S.pBtnDelTxt}>删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Expanded line list */}
|
||||||
|
{isExpanded && (
|
||||||
|
<LineList
|
||||||
|
project={project}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
currentProjectId={currentProjectId}
|
||||||
|
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
|
||||||
|
onExport={handleExportLine}
|
||||||
|
onDelete={handleDeleteLine}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NameModal
|
||||||
|
{...modal}
|
||||||
|
onClose={closeModal}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
@ -1,28 +1,113 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, FlatList, TouchableOpacity, Modal,
|
||||||
Text,
|
StyleSheet, Alert, ActivityIndicator, Platform,
|
||||||
FlatList,
|
useWindowDimensions, ScrollView,
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
Alert,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
|
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||||
import { exportCsv, shareFile } from '../../src/utils/export';
|
import { exportCsv, shareFile } from '../../src/utils/export';
|
||||||
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
|
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';
|
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 (
|
||||||
|
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={M.backdrop}>
|
||||||
|
<View style={M.sheet}>
|
||||||
|
<View style={M.header}>
|
||||||
|
<Text style={M.title}>帧 #{frame.frameId}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[M.scaleBtn, !logScale && M.scaleBtnOn]}
|
||||||
|
onPress={() => setLogScale((v) => !v)}
|
||||||
|
>
|
||||||
|
<Text style={[M.scaleTxt, !logScale && M.scaleTxtOn]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={M.closeBtn} onPress={onClose}>
|
||||||
|
<Text style={M.closeTxt}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<WaveformChart
|
||||||
|
data={data}
|
||||||
|
visibleChannels={visible}
|
||||||
|
width={width - 32}
|
||||||
|
height={CHART_H}
|
||||||
|
logScale={logScale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[{ height: CHART_H }, M.noData]}>
|
||||||
|
<Text style={M.noDataTxt}>无波形数据</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollView horizontal style={M.peakRow} showsHorizontalScrollIndicator={false}>
|
||||||
|
{frame.adcUV.map((ch, i) => {
|
||||||
|
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||||
|
return (
|
||||||
|
<View key={i} style={M.peakCell}>
|
||||||
|
<View style={[M.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
||||||
|
<Text style={M.peakCh}>CH{i + 1}</Text>
|
||||||
|
<Text style={M.peakVal}>{formatUV(pk)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{frame.meta && (
|
||||||
|
<View style={M.meta}>
|
||||||
|
<Text style={M.metaTxt}>
|
||||||
|
UTC {formatUtc(frame.meta.utc)} ×{frame.accNum}叠 增益×{frame.gain}
|
||||||
|
</Text>
|
||||||
|
<Text style={M.metaTxt}>
|
||||||
|
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main screen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RecordsScreen() {
|
export default function RecordsScreen() {
|
||||||
const { history, sessionId, clearHistory } = useDataStore();
|
const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
|
||||||
|
|
||||||
const handleExportAll = async () => {
|
const handleExportAll = async () => {
|
||||||
if (history.length === 0) {
|
if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
|
||||||
Alert.alert('无数据', '当前会话没有采集记录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const path = await exportCsv(history, sessionId);
|
const path = await exportCsv(history, sessionId);
|
||||||
@ -41,119 +126,216 @@ export default function RecordsScreen() {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: MeasurementFrame }) => {
|
const handleNewSession = () => {
|
||||||
const m = item.meta;
|
Alert.alert('新建测线', '结束当前测线,开始新测线?', [
|
||||||
const peakUV = item.adcUV[0]
|
{ text: '取消', style: 'cancel' },
|
||||||
? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
|
{ text: '新建', onPress: () => newSession(projectId) },
|
||||||
: 0;
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<View style={styles.item}>
|
<TouchableOpacity
|
||||||
<View style={styles.itemHeader}>
|
style={S.item}
|
||||||
<Text style={styles.frameId}>#{item.frameId}</Text>
|
onPress={() => setSelectedFrame(item)}
|
||||||
<Text style={styles.time}>{m ? formatUtc(m.utc) : '--'}</Text>
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
<View style={S.itemLeft}>
|
||||||
|
<Text style={S.itemIndex}>{String(history.length - index).padStart(3, '0')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={S.itemBody}>
|
||||||
|
<View style={S.itemHeader}>
|
||||||
|
<Text style={S.frameId}>#{item.frameId}</Text>
|
||||||
|
<Text style={S.time}>{m ? formatUtc(m.utc) : '--'}</Text>
|
||||||
{m && (
|
{m && (
|
||||||
<>
|
<View style={[S.gpsBadge, { backgroundColor: m.gpsStatus > 0 ? '#0d2018' : '#1a0d10' }]}>
|
||||||
<Text style={styles.coord}>
|
<Text style={{ color: m.gpsStatus > 0 ? '#3ddc84' : '#ff5c6e', fontSize: 8, fontWeight: '700' }}>
|
||||||
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.itemFooter}>
|
|
||||||
<Text style={styles.meta}>叠加 {item.accNum}次</Text>
|
|
||||||
<Text style={styles.meta}>增益 ×{item.gain}</Text>
|
|
||||||
<Text style={styles.meta}>CH1峰值 {formatUV(peakUV)}</Text>
|
|
||||||
<View style={[styles.gpsIndicator, { backgroundColor: m.gpsStatus > 0 ? '#1a4a1a' : '#2a1a1a' }]}>
|
|
||||||
<Text style={{ color: m.gpsStatus > 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
|
|
||||||
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
|
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<View style={S.waveHint}>
|
||||||
|
<Text style={S.waveHintText}>波形 ›</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity style={S.itemDelBtn} onPress={() => handleDeleteFrame(item)} hitSlop={8}>
|
||||||
|
<Text style={S.itemDelTxt}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{m && (
|
||||||
|
<Text style={S.coord}>
|
||||||
|
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={S.itemFooter}>
|
||||||
|
<View style={S.peakRow}>
|
||||||
|
{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 (
|
||||||
|
<View key={i} style={S.peakChip}>
|
||||||
|
<View style={[S.peakDot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
||||||
|
<Text style={S.peakVal}>{formatUV(pk)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={S.metaRow}>
|
||||||
|
<Text style={S.metaTag}>×{item.accNum}叠</Text>
|
||||||
|
<Text style={S.metaTag}>增益×{item.gain}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={S.container}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<View style={styles.toolbar}>
|
<View style={S.toolbar}>
|
||||||
<Text style={styles.sessionText}>会话: {sessionId}</Text>
|
<View style={S.toolbarLeft}>
|
||||||
<Text style={styles.countText}>{history.length} 测点</Text>
|
<Text style={S.sessionId} numberOfLines={1}>{sessionId}</Text>
|
||||||
<View style={styles.toolbarBtns}>
|
<Text style={S.count}>{history.length} 测点</Text>
|
||||||
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.toolBtn, styles.exportBtn, exporting && styles.btnDisabled]}
|
style={[S.toolBtn, S.exportBtn, exporting && S.btnDisabled]}
|
||||||
onPress={handleExportAll}
|
onPress={handleExportAll}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{exporting ? (
|
{exporting
|
||||||
<ActivityIndicator color="#44cc44" size="small" />
|
? <ActivityIndicator color="#3ddc84" size="small" />
|
||||||
) : (
|
: <Text style={S.exportText}>导出 CSV</Text>}
|
||||||
<Text style={styles.exportText}>导出 CSV</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[styles.toolBtn, styles.clearBtn]} onPress={handleClear}>
|
<TouchableOpacity style={[S.toolBtn, S.newBtn]} onPress={handleNewSession} activeOpacity={0.8}>
|
||||||
<Text style={styles.clearText}>清空</Text>
|
<Text style={S.newText}>新建</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[S.toolBtn, S.clearBtn]} onPress={handleClear} activeOpacity={0.8}>
|
||||||
|
<Text style={S.clearText}>清空</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={S.empty}>
|
||||||
<Text style={styles.emptyText}>暂无采集记录</Text>
|
<Text style={S.emptyIcon}>◌</Text>
|
||||||
<Text style={styles.emptyHint}>在控制台开始采集后,数据将显示在这里</Text>
|
<Text style={S.emptyText}>暂无采集记录</Text>
|
||||||
|
<Text style={S.emptyHint}>在波形页开始采集后,数据将显示在这里</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={history}
|
data={history}
|
||||||
keyExtractor={(item) => String(item.frameId)}
|
keyExtractor={(item) => String(item.frameId)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={S.list}
|
||||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFrame && (
|
||||||
|
<FrameWaveformModal
|
||||||
|
frame={selectedFrame}
|
||||||
|
onClose={() => setSelectedFrame(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
// ── Styles ──────────────────────────────────────────────────────────────────
|
||||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#090912' },
|
||||||
|
|
||||||
toolbar: {
|
toolbar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row', alignItems: 'center',
|
||||||
alignItems: 'center',
|
paddingHorizontal: 14, paddingVertical: 10,
|
||||||
padding: 10,
|
backgroundColor: '#0c0c18',
|
||||||
backgroundColor: '#111',
|
borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#2a2a2a',
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
},
|
||||||
sessionText: { color: '#555', fontSize: 10, flex: 1 },
|
toolbarLeft: { flex: 1, gap: 2 },
|
||||||
countText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
sessionId: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
toolbarBtns: { flexDirection: 'row', gap: 8 },
|
count: { color: '#6a6a8a', fontSize: 13, fontWeight: '700' },
|
||||||
toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
|
toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
|
||||||
exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
|
exportBtn: { borderColor: '#1a4a28', backgroundColor: '#0d2018' },
|
||||||
exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
|
exportText: { color: '#3ddc84', fontSize: 12, fontWeight: '700' },
|
||||||
clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
|
newBtn: { borderColor: '#2a3a1a', backgroundColor: '#141e0d' },
|
||||||
clearText: { color: '#ff6666', fontSize: 12 },
|
newText: { color: '#aad464', fontSize: 12, fontWeight: '600' },
|
||||||
|
clearBtn: { borderColor: '#3a1520', backgroundColor: '#1e0d12' },
|
||||||
|
clearText: { color: '#ff5c6e', fontSize: 12, fontWeight: '600' },
|
||||||
btnDisabled: { opacity: 0.4 },
|
btnDisabled: { opacity: 0.4 },
|
||||||
list: { padding: 10, gap: 8 },
|
|
||||||
|
list: { padding: 12, gap: 6 },
|
||||||
|
|
||||||
item: {
|
item: {
|
||||||
backgroundColor: '#181818',
|
flexDirection: 'row',
|
||||||
borderRadius: 8,
|
backgroundColor: '#0e0e1c',
|
||||||
padding: 10,
|
borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
|
||||||
borderWidth: 1,
|
overflow: 'hidden',
|
||||||
borderColor: '#2a2a2a',
|
|
||||||
},
|
},
|
||||||
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
itemLeft: {
|
||||||
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
|
width: 36, backgroundColor: '#0a0a14',
|
||||||
time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
|
alignItems: 'center', justifyContent: 'center',
|
||||||
coord: { color: '#888', fontSize: 10, marginBottom: 4 },
|
borderRightWidth: 1, borderRightColor: '#141422',
|
||||||
itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
|
},
|
||||||
meta: { color: '#555', fontSize: 10 },
|
itemIndex: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
|
itemBody: { flex: 1, padding: 10, gap: 5 },
|
||||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
|
|
||||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
|
itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
emptyText: { color: '#444', fontSize: 16 },
|
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
emptyHint: { color: '#333', fontSize: 12 },
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab Two</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
558
app/(tabs)/wave.tsx
Normal file
558
app/(tabs)/wave.tsx
Normal file
@ -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<void>;
|
||||||
|
busy: boolean;
|
||||||
|
configDirty: boolean;
|
||||||
|
}) {
|
||||||
|
const { deviceStatus } = useDeviceStore();
|
||||||
|
const measuring = deviceStatus !== 'idle';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
|
<View style={PS.sheet}>
|
||||||
|
<View style={PS.handle} />
|
||||||
|
|
||||||
|
<View style={PS.header}>
|
||||||
|
<Text style={PS.title}>采集参数</Text>
|
||||||
|
{configDirty && (
|
||||||
|
<View style={PS.dirtyBadge}>
|
||||||
|
<Text style={PS.dirtyTxt}>待下发</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={PS.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
|
||||||
|
<ParamForm />
|
||||||
|
<View style={{ height: Spacing.md }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
PS.setupBtn,
|
||||||
|
configDirty && PS.setupBtnDirty,
|
||||||
|
(busy || measuring) && PS.setupBtnDis,
|
||||||
|
]}
|
||||||
|
onPress={onSetup}
|
||||||
|
disabled={busy || measuring}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? <ActivityIndicator color={Colors.blue.fg} />
|
||||||
|
: <Text style={[PS.setupBtnTxt, configDirty && PS.setupBtnTxtDirty]}>
|
||||||
|
{measuring ? '采集中,不可下发' : '下 发 配 置'}
|
||||||
|
</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={CR.row}>
|
||||||
|
{/* Primary action */}
|
||||||
|
{idle && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[CR.primary, CR.green, !busy ? null : CR.dis]}
|
||||||
|
onPress={onContinuous}
|
||||||
|
disabled={busy}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? <ActivityIndicator color={Colors.green.fg} />
|
||||||
|
: <>
|
||||||
|
<Text style={CR.primaryIcon}>▶</Text>
|
||||||
|
<Text style={[CR.primaryTxt, { color: Colors.green.fg }]}>连 续 采 集</Text>
|
||||||
|
</>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{running && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[CR.primary, CR.red]}
|
||||||
|
onPress={onStop}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={CR.primaryIcon}>■</Text>
|
||||||
|
<Text style={[CR.primaryTxt, { color: Colors.red.fg }]}>停 止</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{single && (
|
||||||
|
<View style={[CR.primary, CR.teal, CR.dis]}>
|
||||||
|
<ActivityIndicator color={Colors.teal.fg} size="small" />
|
||||||
|
<Text style={[CR.primaryTxt, { color: Colors.teal.fg }]}>单次采集中…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary: single-shot button (idle only) */}
|
||||||
|
{idle && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[CR.secondary, CR.tealBtn, !busy ? null : CR.dis]}
|
||||||
|
onPress={onSingle}
|
||||||
|
disabled={busy}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={CR.secondaryIcon}>◎</Text>
|
||||||
|
<Text style={[CR.secondaryTxt, { color: Colors.teal.fg }]}>单次</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Running indicator (running only) */}
|
||||||
|
{running && frameId !== undefined && (
|
||||||
|
<View style={CR.indicator}>
|
||||||
|
<View style={CR.runDot} />
|
||||||
|
<Text style={CR.indicatorTxt}>#{frameId}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings button — always present */}
|
||||||
|
<TouchableOpacity style={CR.settingsBtn} onPress={onSettings} activeOpacity={0.8}>
|
||||||
|
<Text style={[CR.settingsTxt, configDirty && CR.settingsDirty]}>⚙</Text>
|
||||||
|
{configDirty && <View style={CR.dirtyDot} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={PK.scroll}>
|
||||||
|
<View style={PK.row}>
|
||||||
|
{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 (
|
||||||
|
<View key={i} style={PK.cell}>
|
||||||
|
<View style={[PK.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
||||||
|
<Text style={PK.label}>CH{i + 1}</Text>
|
||||||
|
<Text style={[PK.value, { color: CHANNEL_COLORS[i] + 'cc' }]}>{formatUV(pk)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={FS.bar}>
|
||||||
|
<Text style={FS.txt}>
|
||||||
|
#{frame.frameId}
|
||||||
|
{' '}×{frame.accNum}叠
|
||||||
|
{m ? ` ${formatUtc(m.utc)}` : ''}
|
||||||
|
{m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={NC.root}>
|
||||||
|
<Text style={NC.icon}>◈</Text>
|
||||||
|
<Text style={NC.title}>未连接仪器</Text>
|
||||||
|
<Text style={NC.sub}>请先连接 TEM 接收机</Text>
|
||||||
|
<TouchableOpacity style={NC.btn} onPress={onConnect} activeOpacity={0.8}>
|
||||||
|
<Text style={NC.btnTxt}>连 接 设 备</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<View style={S.root}>
|
||||||
|
<NotConnected onConnect={showModal} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={S.root}>
|
||||||
|
|
||||||
|
{/* ── Context bar: project · session ── */}
|
||||||
|
<View style={S.ctxBar}>
|
||||||
|
{projectName
|
||||||
|
? <Text style={S.ctxTxt} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||||
|
: <Text style={S.ctxTxt} numberOfLines={1}>{sessionId}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Channel selector + LOG/LIN ── */}
|
||||||
|
<View style={S.toolbar}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
|
||||||
|
{Array.from({ length: channelNum }, (_, i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
S.chBtn,
|
||||||
|
visible[i] && { borderColor: CHANNEL_COLORS[i] + '99', backgroundColor: CHANNEL_COLORS[i] + '1a' },
|
||||||
|
]}
|
||||||
|
onPress={() => toggleChannel(i)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : Colors.bg.border }]} />
|
||||||
|
<Text style={[S.chTxt, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.logBtn, !logScale && S.logBtnOn]}
|
||||||
|
onPress={() => setLogScale((v) => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[S.logTxt, !logScale && S.logTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Waveform chart ── */}
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
{data ? (
|
||||||
|
<WaveformChart
|
||||||
|
data={data}
|
||||||
|
visibleChannels={visible}
|
||||||
|
width={sw}
|
||||||
|
height={CHART_H}
|
||||||
|
logScale={logScale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[S.chartPlaceholder, { height: CHART_H }]}>
|
||||||
|
<Text style={S.placeholderTxt}>等待采样数据…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Peak values ── */}
|
||||||
|
{frame && <PeakRow frame={frame} visible={visible} channelNum={channelNum} />}
|
||||||
|
|
||||||
|
{/* ── Frame summary ── */}
|
||||||
|
{frame && <FrameSummaryBar frame={frame} />}
|
||||||
|
|
||||||
|
{/* ── Control row ── */}
|
||||||
|
<ControlRow
|
||||||
|
deviceStatus={deviceStatus}
|
||||||
|
busy={busy}
|
||||||
|
frameId={frame?.frameId}
|
||||||
|
onContinuous={startContinuous}
|
||||||
|
onSingle={startSingle}
|
||||||
|
onStop={stop}
|
||||||
|
onSettings={() => setSheetVisible(true)}
|
||||||
|
configDirty={configDirty}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Param sheet ── */}
|
||||||
|
<ParamSheet
|
||||||
|
visible={sheetVisible}
|
||||||
|
onClose={() => setSheetVisible(false)}
|
||||||
|
onSetup={handleSetup}
|
||||||
|
busy={busy}
|
||||||
|
configDirty={configDirty}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
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 { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||||
import { WaveformChart } from '../../src/components/WaveformChart';
|
import { WaveformChart } from '../../src/components/WaveformChart';
|
||||||
import { ChannelSelector } from '../../src/components/ChannelSelector';
|
import { ChannelSelector } from '../../src/components/ChannelSelector';
|
||||||
@ -9,68 +10,79 @@ import { useDeviceStore } from '../../src/stores/deviceStore';
|
|||||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||||
|
|
||||||
export default function WaveformScreen() {
|
export default function WaveformScreen() {
|
||||||
const { width } = useWindowDimensions();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width, height: windowHeight } = useWindowDimensions();
|
||||||
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
||||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
||||||
|
const [logScale, setLogScale] = useState(true);
|
||||||
|
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
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)));
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[S.container, { paddingTop: insets.top }]}>
|
||||||
<DeviceStatusBar />
|
<DeviceStatusBar />
|
||||||
|
|
||||||
|
<View style={S.toolbar}>
|
||||||
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.scaleBtn, !logScale && S.scaleBtnActive]}
|
||||||
|
onPress={() => setLogScale((v) => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[S.scaleBtnText, !logScale && S.scaleBtnTextActive]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Waveform chart */}
|
{/* Waveform chart */}
|
||||||
<View style={styles.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
{data ? (
|
{data ? (
|
||||||
<WaveformChart
|
<WaveformChart
|
||||||
data={data}
|
data={data}
|
||||||
visibleChannels={visible}
|
visibleChannels={visible}
|
||||||
width={width}
|
width={width}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
|
logScale={logScale}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
|
<View style={[S.placeholder, { height: CHART_HEIGHT }]}>
|
||||||
<Text style={styles.placeholderText}>等待采样数据...</Text>
|
<Text style={S.placeholderText}>等待采样数据...</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Channel peak values */}
|
{/* Per-channel peak values + metadata */}
|
||||||
{frame && (
|
{frame && (
|
||||||
<ScrollView style={styles.peakScroll}>
|
<ScrollView style={S.peakScroll}>
|
||||||
<View style={styles.peakRow}>
|
<View style={S.peakRow}>
|
||||||
{frame.adcUV.map((ch, idx) => {
|
{frame.adcUV.map((ch, idx) => {
|
||||||
if (!visible[idx] || idx >= channelNum) return null;
|
if (!visible[idx] || idx >= channelNum) return null;
|
||||||
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||||
const peak = ch[0] < 0 ? -absMax : absMax;
|
|
||||||
return (
|
return (
|
||||||
<View key={idx} style={styles.peakCell}>
|
<View key={idx} style={S.peakCell}>
|
||||||
<Text style={styles.peakLabel}>CH{idx + 1}</Text>
|
<Text style={S.peakLabel}>CH{idx + 1}</Text>
|
||||||
<Text style={styles.peakValue}>{formatUV(absMax)}</Text>
|
<Text style={S.peakValue}>{formatUV(absMax)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Frame metadata */}
|
|
||||||
{frame.meta && (
|
{frame.meta && (
|
||||||
<View style={styles.metaBlock}>
|
<View style={S.metaBlock}>
|
||||||
<Text style={styles.metaText}>
|
<Text style={S.metaText}>
|
||||||
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.metaText}>
|
<Text style={S.metaText}>
|
||||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.metaText}>
|
<Text style={S.metaText}>
|
||||||
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -81,34 +93,36 @@ export default function WaveformScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||||
|
toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
|
||||||
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
||||||
placeholder: {
|
placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' },
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
placeholderText: { color: '#444', fontSize: 14 },
|
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 },
|
peakScroll: { flex: 1 },
|
||||||
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
||||||
peakCell: {
|
peakCell: {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
borderRadius: 8,
|
borderRadius: 8, padding: 10,
|
||||||
padding: 10,
|
minWidth: 90, alignItems: 'center',
|
||||||
minWidth: 90,
|
borderWidth: 1, borderColor: '#2a2a2a',
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2a2a2a',
|
|
||||||
},
|
},
|
||||||
peakLabel: { color: '#888', fontSize: 11 },
|
peakLabel: { color: '#888', fontSize: 11 },
|
||||||
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
||||||
metaBlock: {
|
metaBlock: { margin: 12, backgroundColor: '#111', borderRadius: 8, padding: 10, gap: 3 },
|
||||||
margin: 12,
|
|
||||||
backgroundColor: '#111',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 10,
|
|
||||||
gap: 3,
|
|
||||||
},
|
|
||||||
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
|
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,26 +5,29 @@ import { StatusBar } from 'expo-status-bar';
|
|||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
import { useDataStore } from '../src/stores/dataStore';
|
||||||
|
import { ConnectModal } from '../src/components/modals/ConnectModal';
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
export { ErrorBoundary } from 'expo-router';
|
export { ErrorBoundary } from 'expo-router';
|
||||||
|
|
||||||
export const unstable_settings = { initialRouteName: 'connect' };
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
const init = useDataStore((s) => s.init);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
SplashScreen.hideAsync();
|
init().finally(() => SplashScreen.hideAsync());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={styles.root}>
|
<GestureHandlerRootView style={styles.root}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#0d0d0d' } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#090912' } }}>
|
||||||
<Stack.Screen name="connect" />
|
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{/* ConnectModal is global — accessible from any tab */}
|
||||||
|
<ConnectModal />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
262
app/connect.tsx
262
app/connect.tsx
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -8,7 +8,10 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Linking,
|
Linking,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useConnectionStore } from '../src/stores/connectionStore';
|
import { useConnectionStore } from '../src/stores/connectionStore';
|
||||||
import { useDevice } from '../src/hooks/useDevice';
|
import { useDevice } from '../src/hooks/useDevice';
|
||||||
@ -16,36 +19,35 @@ import { useDevice } from '../src/hooks/useDevice';
|
|||||||
const LOG_MAX = 60;
|
const LOG_MAX = 60;
|
||||||
|
|
||||||
export default function ConnectScreen() {
|
export default function ConnectScreen() {
|
||||||
const { host, port, status, setHost, setPort } = useConnectionStore();
|
const { host, port, status, lastError, setHost, setPort } = useConnectionStore();
|
||||||
const { connect, disconnect } = useDevice();
|
const { connect, disconnect } = useDevice();
|
||||||
const [logs, setLogs] = useState<string[]>(['准备连接...']);
|
const [logs, setLogs] = useState<string[]>(['Ready.']);
|
||||||
const [portStr, setPortStr] = useState(String(port));
|
const [portStr, setPortStr] = useState(String(port));
|
||||||
|
const prevStatusRef = useRef(status);
|
||||||
|
|
||||||
const addLog = (msg: string) => {
|
const addLog = (msg: string) => {
|
||||||
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
|
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConnect = () => {
|
useEffect(() => {
|
||||||
const p = parseInt(portStr, 10);
|
const prev = prevStatusRef.current;
|
||||||
if (!host || isNaN(p)) {
|
prevStatusRef.current = status;
|
||||||
addLog('请检查 IP 和端口');
|
if (status === prev) return;
|
||||||
return;
|
if (status === 'connected') {
|
||||||
}
|
addLog('连接成功 ✓');
|
||||||
setPort(p);
|
router.push('/(tabs)/control');
|
||||||
addLog(`正在连接 ${host}:${p}...`);
|
} else if (status === 'error') {
|
||||||
|
addLog(`错误: ${lastError}`);
|
||||||
// Watch for status changes via the hook (side effect in useDevice)
|
} else if (status === 'reconnecting') {
|
||||||
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('断线,正在重连...');
|
||||||
}
|
}
|
||||||
});
|
}, [status, lastError]);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
const p = parseInt(portStr, 10);
|
||||||
|
if (!host || isNaN(p)) { addLog('请检查 IP 和端口'); return; }
|
||||||
|
setPort(p);
|
||||||
|
addLog(`正在连接 ${host}:${p} ...`);
|
||||||
connect();
|
connect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,150 +60,178 @@ export default function ConnectScreen() {
|
|||||||
const isConnected = status === 'connected';
|
const isConnected = status === 'connected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<SafeAreaView style={S.safe}>
|
||||||
<Text style={styles.title}>TEM Receiver</Text>
|
<KeyboardAvoidingView style={S.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<Text style={styles.subtitle}>瞬变电磁接收机上位机</Text>
|
<ScrollView style={S.scroll} contentContainerStyle={S.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
|
|
||||||
{/* Connection steps */}
|
{/* ── Brand ── */}
|
||||||
<View style={styles.card}>
|
<View style={S.brand}>
|
||||||
<Text style={styles.cardTitle}>连接步骤</Text>
|
<Text style={S.brandIcon}>◈</Text>
|
||||||
<Text style={styles.step}>① 在手机 WiFi 设置中连接设备热点</Text>
|
<Text style={S.title}>TEM Receiver</Text>
|
||||||
<TouchableOpacity style={styles.wifiBtn} onPress={() => Linking.openSettings()}>
|
<Text style={S.subtitle}>瞬变电磁接收机上位机</Text>
|
||||||
<Text style={styles.wifiBtnText}>打开 WiFi 设置 →</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.step}>② 确认连接参数后点击连接</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* IP / Port input */}
|
{/* ── Setup guide ── */}
|
||||||
<View style={styles.card}>
|
<View style={S.card}>
|
||||||
<View style={styles.inputRow}>
|
<Text style={S.cardLabel}>连接步骤</Text>
|
||||||
<Text style={styles.inputLabel}>设备 IP</Text>
|
<View style={S.stepRow}>
|
||||||
|
<View style={S.stepBadge}><Text style={S.stepNum}>1</Text></View>
|
||||||
|
<Text style={S.stepText}>手机连接设备 WiFi 热点</Text>
|
||||||
|
<TouchableOpacity style={S.linkBtn} onPress={() => Linking.openSettings()}>
|
||||||
|
<Text style={S.linkBtnText}>设置 →</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={S.stepRow}>
|
||||||
|
<View style={S.stepBadge}><Text style={S.stepNum}>2</Text></View>
|
||||||
|
<Text style={S.stepText}>确认参数,点击连接</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Inputs ── */}
|
||||||
|
<View style={S.card}>
|
||||||
|
<Text style={S.cardLabel}>连接参数</Text>
|
||||||
|
<View style={S.inputRow}>
|
||||||
|
<Text style={S.inputLabel}>IP 地址</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={S.input}
|
||||||
value={host}
|
value={host}
|
||||||
onChangeText={setHost}
|
onChangeText={setHost}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="192.168.4.1"
|
placeholder="192.168.4.1"
|
||||||
placeholderTextColor="#555"
|
placeholderTextColor="#3a3a5a"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.inputRow}>
|
<View style={S.inputDivider} />
|
||||||
<Text style={styles.inputLabel}>端口</Text>
|
<View style={S.inputRow}>
|
||||||
|
<Text style={S.inputLabel}>端 口</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={S.input}
|
||||||
value={portStr}
|
value={portStr}
|
||||||
onChangeText={setPortStr}
|
onChangeText={setPortStr}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="4321"
|
placeholder="4321"
|
||||||
placeholderTextColor="#555"
|
placeholderTextColor="#3a3a5a"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Connect / Disconnect button */}
|
{/* ── Action ── */}
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.connectBtn, isConnecting && styles.connectBtnBusy]}
|
style={[S.connectBtn, isConnecting && S.connectBtnBusy]}
|
||||||
onPress={handleConnect}
|
onPress={handleConnect}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{isConnecting ? (
|
{isConnecting
|
||||||
<ActivityIndicator color="#fff" />
|
? <ActivityIndicator color="#fff" />
|
||||||
) : (
|
: <Text style={S.connectBtnText}>连 接 设 备</Text>}
|
||||||
<Text style={styles.connectBtnText}>连 接 设 备</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.connectedRow}>
|
<View style={S.connectedBox}>
|
||||||
<View style={styles.connectedBadge}>
|
<View style={S.connectedLeft}>
|
||||||
<View style={styles.greenDot} />
|
<View style={S.greenDot} />
|
||||||
<Text style={styles.connectedText}>已连接</Text>
|
<Text style={S.connectedText}>已连接</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.goBtn} onPress={() => router.replace('/(tabs)/control')}>
|
<TouchableOpacity style={S.goBtn} onPress={() => router.push('/(tabs)/control')} activeOpacity={0.8}>
|
||||||
<Text style={styles.goBtnText}>进入控制台 →</Text>
|
<Text style={S.goBtnText}>进入控制台 →</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
|
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect} activeOpacity={0.8}>
|
||||||
<Text style={styles.disconnectBtnText}>断开</Text>
|
<Text style={S.disconnectBtnText}>断开</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connection log */}
|
{/* ── Log terminal ── */}
|
||||||
<View style={styles.logCard}>
|
<View style={S.terminal}>
|
||||||
<Text style={styles.logTitle}>连接日志</Text>
|
<Text style={S.terminalHeader}>● 连接日志</Text>
|
||||||
<ScrollView style={styles.logScroll} nestedScrollEnabled>
|
<View style={S.terminalBody}>
|
||||||
{logs.map((l, i) => (
|
{logs.map((l, i) => (
|
||||||
<Text key={i} style={styles.logLine}>{l}</Text>
|
<Text key={i} style={[S.terminalLine, i === 0 && S.terminalLineLatest]}>{l}</Text>
|
||||||
))}
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</View>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
|
safe: { flex: 1, backgroundColor: '#090912' },
|
||||||
title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
|
flex: { flex: 1 },
|
||||||
subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
|
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: {
|
card: {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#111120',
|
||||||
borderRadius: 10,
|
borderRadius: 14,
|
||||||
padding: 14,
|
|
||||||
marginBottom: 12,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: '#22223a',
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
cardTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600', marginBottom: 8 },
|
cardLabel: { color: '#4a4a6a', fontSize: 10, fontWeight: '600', letterSpacing: 1.5, marginBottom: 12, textTransform: 'uppercase' },
|
||||||
step: { color: '#bbb', fontSize: 13, marginBottom: 6 },
|
|
||||||
wifiBtn: {
|
// Steps
|
||||||
backgroundColor: '#0d2b55',
|
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
|
||||||
borderRadius: 6,
|
stepBadge: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#1a2a44', justifyContent: 'center', alignItems: 'center' },
|
||||||
paddingVertical: 6,
|
stepNum: { color: '#4a9eff', fontSize: 11, fontWeight: '700' },
|
||||||
paddingHorizontal: 12,
|
stepText: { color: '#8888aa', fontSize: 13, flex: 1 },
|
||||||
alignSelf: 'flex-start',
|
linkBtn: { backgroundColor: '#0d2040', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#1a3a66' },
|
||||||
marginBottom: 10,
|
linkBtnText: { color: '#4a9eff', fontSize: 11, fontWeight: '600' },
|
||||||
},
|
|
||||||
wifiBtnText: { color: '#4a9eff', fontSize: 12 },
|
// Inputs
|
||||||
inputRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
inputRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
inputLabel: { color: '#888', fontSize: 13, width: 50 },
|
inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 10 },
|
||||||
|
inputLabel: { color: '#5a5a7a', fontSize: 12, width: 54, letterSpacing: 0.3 },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#111',
|
color: '#d0d0e8',
|
||||||
color: '#eee',
|
fontSize: 15,
|
||||||
borderRadius: 6,
|
fontWeight: '500',
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
fontSize: 14,
|
borderBottomWidth: 1,
|
||||||
borderWidth: 1,
|
borderBottomColor: '#2a2a48',
|
||||||
borderColor: '#333',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Connect button
|
||||||
connectBtn: {
|
connectBtn: {
|
||||||
backgroundColor: '#4a9eff',
|
backgroundColor: '#1a3a6e',
|
||||||
borderRadius: 10,
|
borderRadius: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 12,
|
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,
|
borderWidth: 1,
|
||||||
borderColor: '#222',
|
borderColor: '#4a9eff',
|
||||||
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
logTitle: { color: '#666', fontSize: 11, marginBottom: 4 },
|
connectBtnBusy: { borderColor: '#2a4a7a', backgroundColor: '#0f1f3a' },
|
||||||
logScroll: { flex: 1 },
|
connectBtnText: { color: '#4a9eff', fontSize: 16, fontWeight: '700', letterSpacing: 3 },
|
||||||
logLine: { color: '#555', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 },
|
|
||||||
|
// 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' },
|
||||||
});
|
});
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"expo": "~56.0.9",
|
"expo": "~56.0.9",
|
||||||
"expo-constants": "~56.0.17",
|
"expo-constants": "~56.0.17",
|
||||||
"expo-dev-client": "~56.0.19",
|
"expo-dev-client": "~56.0.19",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.7",
|
"expo-file-system": "~56.0.7",
|
||||||
"expo-font": "~56.0.5",
|
"expo-font": "~56.0.5",
|
||||||
"expo-linking": "~56.0.13",
|
"expo-linking": "~56.0.13",
|
||||||
@ -23,6 +24,7 @@
|
|||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"expo-symbols": "~56.0.6",
|
"expo-symbols": "~56.0.6",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
@ -3960,6 +3962,15 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "56.0.7",
|
"version": "56.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz",
|
||||||
@ -4562,6 +4573,12 @@
|
|||||||
"integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==",
|
"integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
"expo": "~56.0.9",
|
"expo": "~56.0.9",
|
||||||
"expo-constants": "~56.0.17",
|
"expo-constants": "~56.0.17",
|
||||||
"expo-dev-client": "~56.0.19",
|
"expo-dev-client": "~56.0.19",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.7",
|
"expo-file-system": "~56.0.7",
|
||||||
"expo-font": "~56.0.5",
|
"expo-font": "~56.0.5",
|
||||||
"expo-linking": "~56.0.13",
|
"expo-linking": "~56.0.13",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"expo-symbols": "~56.0.6",
|
"expo-symbols": "~56.0.6",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
|
|||||||
23
run-android.bat
Normal file
23
run-android.bat
Normal file
@ -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
|
||||||
26
run-android.ps1
Normal file
26
run-android.ps1
Normal file
@ -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
|
||||||
@ -10,35 +10,42 @@ interface Props {
|
|||||||
|
|
||||||
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={S.row}>
|
||||||
{Array.from({ length: maxChannels }, (_, i) => (
|
{Array.from({ length: maxChannels }, (_, i) => {
|
||||||
|
const color = CHANNEL_COLORS[i];
|
||||||
|
const active = visible[i];
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={i}
|
key={i}
|
||||||
style={[styles.chip, { borderColor: CHANNEL_COLORS[i], opacity: visible[i] ? 1 : 0.35 }]}
|
style={[
|
||||||
|
S.chip,
|
||||||
|
{ borderColor: active ? color + '88' : '#1e1e30' },
|
||||||
|
active && { backgroundColor: color + '14' },
|
||||||
|
]}
|
||||||
onPress={() => onToggle(i)}
|
onPress={() => onToggle(i)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[styles.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
<View style={[S.dot, { backgroundColor: active ? color : '#2a2a3a' }]} />
|
||||||
<Text style={[styles.label, { color: visible[i] ? CHANNEL_COLORS[i] : '#888' }]}>
|
<Text style={[S.label, { color: active ? color : '#3a3a5a' }]}>CH{i + 1}</Text>
|
||||||
CH{i + 1}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
|
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingVertical: 10 },
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 12,
|
borderRadius: 20,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 3,
|
paddingVertical: 5,
|
||||||
gap: 4,
|
backgroundColor: '#0c0c18',
|
||||||
},
|
},
|
||||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
label: { fontSize: 11, fontWeight: '600' },
|
label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import React from 'react';
|
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 { useDeviceStore } from '../stores/deviceStore';
|
||||||
import { useDataStore } from '../stores/dataStore';
|
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 (
|
return (
|
||||||
<View style={styles.indicator}>
|
<View style={[S.chip, color ? { borderColor: color + '44' } : null]}>
|
||||||
<View style={[styles.dot, { backgroundColor: ok === false ? '#ff4444' : ok ? '#44cc44' : '#888888' }]} />
|
{color && <View style={[S.chipDot, { backgroundColor: color }]} />}
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={S.chipLabel}>{label}</Text>
|
||||||
<Text style={styles.value}>{value}</Text>
|
<Text style={[S.chipValue, color ? { color } : null]}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -19,55 +19,68 @@ export function DeviceStatusBar() {
|
|||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
const meta = frame?.meta;
|
const meta = frame?.meta;
|
||||||
|
|
||||||
|
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
|
||||||
const lat = meta?.latitude ?? 0;
|
const lat = meta?.latitude ?? 0;
|
||||||
const lon = meta?.longitude ?? 0;
|
const lon = meta?.longitude ?? 0;
|
||||||
const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
|
const coordStr = hasGps && lat ? `${lat.toFixed(4)}, ${lon.toFixed(4)}` : '--';
|
||||||
const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
|
|
||||||
|
|
||||||
const running = deviceStatus === 'running';
|
const running = deviceStatus === 'running';
|
||||||
const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
|
const single = deviceStatus === 'single';
|
||||||
const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
|
const statusColor = running ? '#3ddc84' : single ? '#ffb84d' : '#3a3a5a';
|
||||||
|
const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.bar}>
|
<View style={S.bar}>
|
||||||
<View style={[styles.statusBadge, { borderColor: statusColor }]}>
|
<View style={[S.statusPill, { borderColor: statusColor + '55', backgroundColor: statusColor + '18' }]}>
|
||||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
<Text style={[S.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
||||||
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Indicator label="GPS" value={gpsStr} ok={hasGps ? true : false} />
|
<Chip label="GPS" value={coordStr} color={hasGps ? '#3ddc84' : '#3a3a5a'} />
|
||||||
<Indicator label="SD" value={sdStatus > 0 ? 'OK' : '--'} ok={sdStatus > 0} />
|
<Chip label="SD" value={sdStatus > 0 ? 'OK' : '--'} color={sdStatus > 0 ? '#4a9eff' : '#3a3a5a'} />
|
||||||
<Indicator label="电池" value={batteryVolt ? formatBattery(batteryVolt) : '--'} />
|
{!!batteryVolt && <Chip label="电池" value={formatBattery(batteryVolt)} />}
|
||||||
<Indicator label="温度" value={temperature ? formatTemperature(temperature) : '--'} />
|
{!!temperature && <Chip label="温度" value={formatTemperature(temperature)} />}
|
||||||
|
{frame && (
|
||||||
|
<View style={S.frameCount}>
|
||||||
|
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
bar: {
|
bar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: '#111',
|
backgroundColor: '#0c0c18',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 10,
|
gap: 6,
|
||||||
borderBottomWidth: 1,
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 2,
|
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
backgroundColor: '#111120',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#22223a',
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 3,
|
||||||
},
|
},
|
||||||
statusDot: { width: 6, height: 6, borderRadius: 3 },
|
chipDot: { width: 5, height: 5, borderRadius: 3 },
|
||||||
statusText: { fontSize: 11, fontWeight: '600' },
|
chipLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
|
||||||
indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
chipValue: { color: '#6a6a8a', fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
frameCount: { marginLeft: 'auto' },
|
||||||
label: { color: '#888', fontSize: 10 },
|
frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch, Platform } from 'react-native';
|
||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import {
|
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
|
||||||
SEND_FREQ_TABLE,
|
|
||||||
SAMPLE_FREQ_TABLE,
|
|
||||||
AMP_RATIO_TABLE,
|
|
||||||
SOURCE_MODE_TABLE,
|
|
||||||
} from '../protocol/constants';
|
|
||||||
|
|
||||||
interface PickerRowProps {
|
interface PickerRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@ -17,20 +12,22 @@ interface PickerRowProps {
|
|||||||
|
|
||||||
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={S.row}>
|
||||||
<Text style={styles.rowLabel}>{label}</Text>
|
<Text style={S.rowLabel}>{label}</Text>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.optScroll}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={S.optScroll}>
|
||||||
{options.map((o) => (
|
{options.map((o) => {
|
||||||
|
const active = o.code === value;
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={o.code}
|
key={o.code}
|
||||||
style={[styles.optChip, o.code === value && styles.optChipActive]}
|
style={[S.optChip, active && S.optChipActive]}
|
||||||
onPress={() => onChange(o.code)}
|
onPress={() => onChange(o.code)}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optText, o.code === value && styles.optTextActive]}>
|
<Text style={[S.optText, active && S.optTextActive]}>{o.label}</Text>
|
||||||
{o.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -39,34 +36,63 @@ function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
|||||||
interface NumberRowProps {
|
interface NumberRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
onChangeText: (v: string) => void;
|
min: number;
|
||||||
|
max: number;
|
||||||
|
onCommit: (v: number) => void;
|
||||||
suffix?: string;
|
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 (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={S.row}>
|
||||||
<Text style={styles.rowLabel}>{label}</Text>
|
<Text style={S.rowLabel}>{label}</Text>
|
||||||
<View style={styles.inputWrap}>
|
<View style={S.inputWrap}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[S.input, invalid && S.inputInvalid]}
|
||||||
value={String(value)}
|
value={draft}
|
||||||
onChangeText={onChangeText}
|
onChangeText={setDraft}
|
||||||
keyboardType={keyboardType}
|
onBlur={commit}
|
||||||
|
keyboardType="numeric"
|
||||||
selectTextOnFocus
|
selectTextOnFocus
|
||||||
placeholderTextColor="#555"
|
placeholderTextColor="#3a3a5a"
|
||||||
/>
|
/>
|
||||||
{suffix && <Text style={styles.suffix}>{suffix}</Text>}
|
{suffix && <Text style={S.suffix}>{suffix}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
{invalid && (
|
||||||
|
<Text style={S.hint}>{min}–{max}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormProps {
|
function SectionHeader({ title }: { title: string }) {
|
||||||
onAnyChange?: () => void;
|
return (
|
||||||
|
<View style={S.sectionHeader}>
|
||||||
|
<Text style={S.sectionTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormProps { onAnyChange?: () => void }
|
||||||
|
|
||||||
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
||||||
const { config, updateConfig } = useDeviceStore();
|
const { config, updateConfig } = useDeviceStore();
|
||||||
const patch = (partial: Partial<typeof config>) => {
|
const patch = (partial: Partial<typeof config>) => {
|
||||||
@ -75,161 +101,166 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={S.container}>
|
||||||
{/* Channel selector */}
|
|
||||||
<View style={styles.row}>
|
<SectionHeader title="采集配置" />
|
||||||
<Text style={styles.rowLabel}>通道数</Text>
|
|
||||||
<View style={styles.channelRow}>
|
{/* Channel count */}
|
||||||
{[1, 2, 3, 4, 5, 6].map((n) => (
|
<View style={S.row}>
|
||||||
|
<Text style={S.rowLabel}>通道数</Text>
|
||||||
|
<View style={S.channelRow}>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((n) => {
|
||||||
|
const active = config.channelNum === n;
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={n}
|
key={n}
|
||||||
style={[styles.chChip, config.channelNum === n && styles.chChipActive]}
|
style={[S.chChip, active && S.chChipActive]}
|
||||||
onPress={() => patch({ channelNum: n })}
|
onPress={() => patch({ channelNum: n })}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.chText, config.channelNum === n && styles.chTextActive]}>
|
<Text style={[S.chText, active && S.chTextActive]}>{n}</Text>
|
||||||
{n}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<PickerRow
|
<PickerRow label="发射频率" options={SEND_FREQ_TABLE} value={config.sendFreq} onChange={(v) => patch({ sendFreq: v })} />
|
||||||
label="发射频率"
|
<PickerRow label="采样频率" options={SAMPLE_FREQ_TABLE} value={config.sampleFreq} onChange={(v) => patch({ sampleFreq: v })} />
|
||||||
options={SEND_FREQ_TABLE}
|
|
||||||
value={config.sendFreq}
|
|
||||||
onChange={(v) => patch({ sendFreq: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PickerRow
|
|
||||||
label="采样频率"
|
|
||||||
options={SAMPLE_FREQ_TABLE}
|
|
||||||
value={config.sampleFreq}
|
|
||||||
onChange={(v) => patch({ sampleFreq: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumberRow
|
<NumberRow
|
||||||
label="采样点数"
|
label="采样点数" value={config.sampleDepth}
|
||||||
value={config.sampleDepth}
|
min={64} max={65535}
|
||||||
onChangeText={(v) => patch({ sampleDepth: parseInt(v, 10) || 0 })}
|
onCommit={(v) => { patch({ sampleDepth: v }); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberRow
|
<NumberRow
|
||||||
label="叠加次数"
|
label="叠加次数" value={config.accNum}
|
||||||
value={config.accNum}
|
min={1} max={9999}
|
||||||
onChangeText={(v) => patch({ accNum: parseInt(v, 10) || 0 })}
|
onCommit={(v) => { patch({ accNum: v }); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PickerRow
|
<SectionHeader title="放大与源" />
|
||||||
label="增益"
|
|
||||||
options={AMP_RATIO_TABLE}
|
|
||||||
value={config.ampRatio}
|
|
||||||
onChange={(v) => patch({ ampRatio: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PickerRow
|
<PickerRow label="增 益" options={AMP_RATIO_TABLE} value={config.ampRatio} onChange={(v) => patch({ ampRatio: v })} />
|
||||||
label="源模式"
|
<PickerRow label="源模式" options={SOURCE_MODE_TABLE} value={config.sourceMode} onChange={(v) => patch({ sourceMode: v })} />
|
||||||
options={SOURCE_MODE_TABLE}
|
|
||||||
value={config.sourceMode}
|
|
||||||
onChange={(v) => patch({ sourceMode: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Reverse accumulation flags */}
|
<SectionHeader title="高级设置" />
|
||||||
<View style={styles.row}>
|
|
||||||
<Text style={styles.rowLabel}>反向叠加</Text>
|
{/* Reverse accumulation */}
|
||||||
<View style={styles.switchRow}>
|
<View style={S.row}>
|
||||||
<Text style={styles.switchLabel}>CH1-3</Text>
|
<Text style={S.rowLabel}>反向叠加</Text>
|
||||||
|
<View style={S.switchRow}>
|
||||||
|
<Text style={S.switchLabel}>CH1-3</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={config.negAcc123}
|
value={config.negAcc123}
|
||||||
onValueChange={(v) => patch({ negAcc123: v })}
|
onValueChange={(v) => patch({ negAcc123: v })}
|
||||||
thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
|
thumbColor={config.negAcc123 ? '#4a9eff' : '#444'}
|
||||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.switchLabel}>CH4-6</Text>
|
<Text style={S.switchLabel}>CH4-6</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={config.negAcc456}
|
value={config.negAcc456}
|
||||||
onValueChange={(v) => patch({ negAcc456: v })}
|
onValueChange={(v) => patch({ negAcc456: v })}
|
||||||
thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
|
thumbColor={config.negAcc456 ? '#4a9eff' : '#444'}
|
||||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<NumberRow
|
<NumberRow
|
||||||
label="补偿延时"
|
label="补偿延时" value={config.compDisableDelay}
|
||||||
value={config.compDisableDelay}
|
min={0} max={65535}
|
||||||
onChangeText={(v) => patch({ compDisableDelay: parseInt(v, 10) || 0 })}
|
onCommit={(v) => { patch({ compDisableDelay: v }); }}
|
||||||
suffix="×50μs"
|
suffix="×50μs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File prefix */}
|
{/* File prefix */}
|
||||||
<View style={styles.row}>
|
<View style={S.row}>
|
||||||
<Text style={styles.rowLabel}>文件前缀</Text>
|
<Text style={S.rowLabel}>文件前缀</Text>
|
||||||
<View style={styles.inputWrap}>
|
<View style={S.inputWrap}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={S.input}
|
||||||
value={config.filePrefix}
|
value={config.filePrefix}
|
||||||
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
||||||
maxLength={15}
|
maxLength={15}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
placeholderTextColor="#555"
|
placeholderTextColor="#3a3a5a"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { backgroundColor: '#111' },
|
container: { backgroundColor: '#090912' },
|
||||||
|
|
||||||
|
sectionHeader: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: 6,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#3a3a5a',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingVertical: 10,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#2a2a2a',
|
borderBottomColor: '#141422',
|
||||||
gap: 10,
|
gap: 12,
|
||||||
|
backgroundColor: '#0e0e1c',
|
||||||
|
marginHorizontal: 0,
|
||||||
},
|
},
|
||||||
rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
|
rowLabel: { color: '#4a4a6a', fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 },
|
||||||
|
|
||||||
|
// Picker chips
|
||||||
optScroll: { flex: 1 },
|
optScroll: { flex: 1 },
|
||||||
optChip: {
|
optChip: { borderWidth: 1, borderColor: '#1e1e30', borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
|
||||||
borderWidth: 1,
|
optChipActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
|
||||||
borderColor: '#444',
|
optText: { color: '#3a3a5a', fontSize: 11 },
|
||||||
borderRadius: 8,
|
optTextActive: { color: '#4a9eff', fontWeight: '700' },
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 3,
|
// Number input
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
|
||||||
optText: { color: '#888', fontSize: 11 },
|
|
||||||
optTextActive: { color: '#4a9eff', fontWeight: '600' },
|
|
||||||
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#1e1e1e',
|
color: '#c0c0d8',
|
||||||
color: '#eee',
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
borderRadius: 6,
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderColor: '#333',
|
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 },
|
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
|
||||||
chChip: {
|
chChip: {
|
||||||
width: 28,
|
width: 30,
|
||||||
height: 28,
|
height: 30,
|
||||||
borderRadius: 14,
|
borderRadius: 15,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#444',
|
borderColor: '#1e1e30',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#0c0c18',
|
||||||
},
|
},
|
||||||
chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' },
|
||||||
chText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' },
|
||||||
chTextActive: { color: '#4a9eff' },
|
chTextActive: { color: '#4a9eff' },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
|
|
||||||
switchLabel: { color: '#888', fontSize: 12 },
|
// Switch row
|
||||||
|
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
|
||||||
|
switchLabel: { color: '#4a4a6a', fontSize: 12 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,145 +9,217 @@ interface Props {
|
|||||||
visibleChannels: boolean[];
|
visibleChannels: boolean[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
logScale?: boolean; // default true
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
|
const PADDING = { top: 14, right: 14, bottom: 34, left: 56 };
|
||||||
const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
|
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) {
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 plotW = width - PADDING.left - PADDING.right;
|
||||||
const plotH = height - PADDING.top - PADDING.bottom;
|
const plotH = height - PADDING.top - PADDING.bottom;
|
||||||
|
|
||||||
const yMin = data.minLogUV - 0.5;
|
// ── Y mapping ────────────────────────────────────────────────────────────
|
||||||
const yMax = data.maxLogUV + 0.5;
|
const yLogMin = data.minLogUV - 0.5;
|
||||||
const yRange = yMax - yMin;
|
const yLogMax = data.maxLogUV + 0.5;
|
||||||
|
const yLogRange = yLogMax - yLogMin;
|
||||||
|
|
||||||
// Map log10(absVal) → canvas y (top=high, bottom=low)
|
const yLinMin = data.minUV;
|
||||||
const toY = (uv: number): number => {
|
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);
|
const absV = Math.abs(uv);
|
||||||
if (absV < 1e-12) return PADDING.top + plotH;
|
if (absV < 1e-12) return PADDING.top + plotH;
|
||||||
const logV = Math.log10(absV);
|
const logV = Math.log10(absV);
|
||||||
return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
|
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]);
|
||||||
|
|
||||||
// Map sample index → canvas x
|
// ── X mapping ────────────────────────────────────────────────────────────
|
||||||
const toX = (i: number, total: number): number =>
|
const toX = (i: number, total: number): number =>
|
||||||
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
|
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
|
||||||
|
|
||||||
// Y-axis grid lines and labels
|
const toXms = (ms: number): number =>
|
||||||
const yGridLines = useMemo(() => {
|
PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW;
|
||||||
return Y_TICKS.filter((v) => {
|
|
||||||
const log = Math.log10(v);
|
|
||||||
return log >= yMin && log <= yMax;
|
|
||||||
});
|
|
||||||
}, [yMin, yMax]);
|
|
||||||
|
|
||||||
// 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(() => {
|
const channelPaths = useMemo(() => {
|
||||||
return data.channels.map((ch, idx) => {
|
return data.channels.map((ch, idx) => {
|
||||||
if (!visibleChannels[idx]) return null;
|
if (!visibleChannels[idx]) return null;
|
||||||
const path = Skia.Path.Make();
|
const path = Skia.Path.Make();
|
||||||
let moved = false;
|
let moved = false;
|
||||||
for (let i = 0; i < ch.length; i++) {
|
for (let i = 0; i < ch.length; i++) {
|
||||||
|
if (logScale && Math.abs(ch[i]) < 1e-12) continue;
|
||||||
const x = toX(i, ch.length);
|
const x = toX(i, ch.length);
|
||||||
const y = toY(ch[i]);
|
const y = toY(ch[i]);
|
||||||
if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
|
if (!isFinite(y)) continue;
|
||||||
if (!moved) {
|
if (!moved) { path.moveTo(x, y); moved = true; }
|
||||||
path.moveTo(x, y);
|
else path.lineTo(x, y);
|
||||||
moved = true;
|
|
||||||
} else {
|
|
||||||
path.lineTo(x, y);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return path;
|
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 (
|
return (
|
||||||
<View style={[styles.container, { width, height }]}>
|
<View style={[S.container, { width, height }]}>
|
||||||
<Canvas style={{ width, height }}>
|
<Canvas style={{ width, height }}>
|
||||||
{/* Background */}
|
|
||||||
|
|
||||||
{/* Y-axis grid lines */}
|
{/* Y grid lines */}
|
||||||
{yGridLines.map((v) => {
|
{yGridValues.map((v) => {
|
||||||
const y = toY(v);
|
const y = toY(v);
|
||||||
|
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
|
||||||
key={v}
|
color="#333333" strokeWidth={0.5} />
|
||||||
p1={vec(PADDING.left, y)}
|
|
||||||
p2={vec(PADDING.left + plotW, y)}
|
|
||||||
color="#333333"
|
|
||||||
strokeWidth={0.5}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* X-axis baseline */}
|
{/* X grid lines */}
|
||||||
<Line
|
{xTickValues.map((ms) => {
|
||||||
p1={vec(PADDING.left, PADDING.top + plotH)}
|
const x = toXms(ms);
|
||||||
p2={vec(PADDING.left + plotW, PADDING.top + plotH)}
|
return (
|
||||||
color="#666666"
|
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
|
||||||
strokeWidth={1}
|
color="#2a2a2a" strokeWidth={0.5} />
|
||||||
/>
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Channel waveform paths */}
|
{/* Baseline */}
|
||||||
|
{!logScale && (
|
||||||
|
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))}
|
||||||
|
color="#555555" strokeWidth={0.8} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X axis */}
|
||||||
|
<Line p1={vec(left, bot)} p2={vec(right, bot)}
|
||||||
|
color="#555555" strokeWidth={1} />
|
||||||
|
|
||||||
|
{/* Channel paths */}
|
||||||
{channelPaths.map((path, idx) => {
|
{channelPaths.map((path, idx) => {
|
||||||
if (!path || !visibleChannels[idx]) return null;
|
if (!path || !visibleChannels[idx]) return null;
|
||||||
return (
|
return (
|
||||||
<Path
|
<Path key={idx} path={path}
|
||||||
key={idx}
|
|
||||||
path={path}
|
|
||||||
color={CHANNEL_COLORS[idx]}
|
color={CHANNEL_COLORS[idx]}
|
||||||
style="stroke"
|
style="stroke" strokeWidth={1.5}
|
||||||
strokeWidth={1.5}
|
strokeJoin="round" strokeCap="round" />
|
||||||
strokeJoin="round"
|
|
||||||
strokeCap="round"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
{/* Y-axis labels (React Native text overlay, positioned absolutely) */}
|
{/* Y-axis labels */}
|
||||||
{yGridLines.map((v) => {
|
{yGridValues.map((v) => {
|
||||||
const y = toY(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 (
|
return (
|
||||||
<Text
|
<Text key={v}
|
||||||
key={v}
|
style={[S.yLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}>
|
||||||
style={[styles.axisLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* X-axis label */}
|
{/* X-axis tick labels */}
|
||||||
<Text style={[styles.xLabel, { top: height - 18, left: PADDING.left }]}>
|
{xTickValues.map((ms) => {
|
||||||
时间 →
|
const x = toXms(ms);
|
||||||
|
return (
|
||||||
|
<Text key={ms}
|
||||||
|
style={[S.xLabel, { top: height - 20, left: x - 18, width: 36 }]}>
|
||||||
|
{formatMsShort(ms)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Axis unit labels */}
|
||||||
|
<Text style={[S.unitLabel, { top: 2, left: 2 }]}>μV</Text>
|
||||||
|
<Text style={[S.unitLabel, { top: height - 14, left: PADDING.left + plotW - 12 }]}>
|
||||||
|
{data.totalTimeMs >= 1000 ? 's' : 'ms'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Y-axis unit */}
|
{/* Scale mode badge */}
|
||||||
<Text style={[styles.yUnit, { top: 0, left: 0 }]}>μV</Text>
|
<Text style={[S.scaleBadge, { top: 2, right: 4 }]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { backgroundColor: '#1a1a1a', position: 'relative' },
|
container: { backgroundColor: '#111111', position: 'relative' },
|
||||||
axisLabel: {
|
yLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'right' },
|
||||||
position: 'absolute',
|
xLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'center' },
|
||||||
color: '#aaaaaa',
|
unitLabel: { position: 'absolute', color: '#555', fontSize: 9 },
|
||||||
fontSize: 9,
|
scaleBadge: { position: 'absolute', color: '#444', fontSize: 8, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
xLabel: {
|
|
||||||
position: 'absolute',
|
|
||||||
color: '#aaaaaa',
|
|
||||||
fontSize: 9,
|
|
||||||
},
|
|
||||||
yUnit: {
|
|
||||||
position: 'absolute',
|
|
||||||
color: '#888888',
|
|
||||||
fontSize: 9,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
120
src/components/device/GlobalStatusBar.tsx
Normal file
120
src/components/device/GlobalStatusBar.tsx
Normal file
@ -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 = (
|
||||||
|
<View style={[S.pill, { borderColor: color + '44' }]}>
|
||||||
|
<View style={[S.dot, { backgroundColor: color }]} />
|
||||||
|
<Text style={S.pillLabel}>{label}</Text>
|
||||||
|
<Text style={[S.pillValue, { color }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
if (onPress) return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{inner}</TouchableOpacity>;
|
||||||
|
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 (
|
||||||
|
<View style={S.bar}>
|
||||||
|
{/* Connection — tappable */}
|
||||||
|
<Pill label="TCP" value={connLabel} color={connColor} onPress={showModal} />
|
||||||
|
|
||||||
|
{/* Device run state */}
|
||||||
|
{connected && (
|
||||||
|
<View style={[S.pill, { borderColor: stateColor + '44' }]}>
|
||||||
|
<Text style={[S.stateText, { color: stateColor }]}>{stateLabel}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPS */}
|
||||||
|
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? Colors.green.fg : Colors.text.ghost} />
|
||||||
|
|
||||||
|
{/* SD */}
|
||||||
|
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? Colors.blue.fg : Colors.text.ghost} />
|
||||||
|
|
||||||
|
{/* Battery */}
|
||||||
|
{batteryVolt > 0 && (
|
||||||
|
<Pill label="电量" value={formatBattery(batteryVolt)} color={Colors.text.muted} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Temperature */}
|
||||||
|
{temperature > 0 && (
|
||||||
|
<Pill label="温度" value={formatTemperature(temperature)} color={Colors.text.muted} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Frame counter — right-aligned */}
|
||||||
|
{frame && (
|
||||||
|
<View style={S.frameCount}>
|
||||||
|
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
277
src/components/modals/ConnectModal.tsx
Normal file
277
src/components/modals/ConnectModal.tsx
Normal file
@ -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<string[]>(['就绪']);
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
visible={modalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={hideModal}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={S.backdrop} activeOpacity={1} onPress={hideModal} />
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={S.kav}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<View style={S.sheet}>
|
||||||
|
{/* Handle */}
|
||||||
|
<View style={S.handle} />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text style={S.title}>连接仪器</Text>
|
||||||
|
|
||||||
|
{/* Guide */}
|
||||||
|
<View style={S.guideRow}>
|
||||||
|
<View style={S.stepBadge}><Text style={S.stepNum}>1</Text></View>
|
||||||
|
<Text style={S.stepTxt}>手机连接设备 WiFi 热点</Text>
|
||||||
|
<TouchableOpacity style={S.settingsBtn} onPress={() => Linking.openSettings()}>
|
||||||
|
<Text style={S.settingsTxt}>设置 →</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={[S.guideRow, { marginBottom: Spacing.md }]}>
|
||||||
|
<View style={S.stepBadge}><Text style={S.stepNum}>2</Text></View>
|
||||||
|
<Text style={S.stepTxt}>确认参数后点击连接</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Inputs */}
|
||||||
|
<View style={S.inputCard}>
|
||||||
|
<View style={S.inputRow}>
|
||||||
|
<Text style={S.inputLabel}>IP 地址</Text>
|
||||||
|
<TextInput
|
||||||
|
style={S.input}
|
||||||
|
value={host}
|
||||||
|
onChangeText={setHost}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="192.168.4.1"
|
||||||
|
placeholderTextColor={Colors.text.ghost}
|
||||||
|
autoCapitalize="none"
|
||||||
|
editable={!connecting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={S.inputDivider} />
|
||||||
|
<View style={S.inputRow}>
|
||||||
|
<Text style={S.inputLabel}>端 口</Text>
|
||||||
|
<TextInput
|
||||||
|
style={S.input}
|
||||||
|
value={portStr}
|
||||||
|
onChangeText={setPortStr}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="4321"
|
||||||
|
placeholderTextColor={Colors.text.ghost}
|
||||||
|
editable={!connecting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{!connected ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.connectBtn, connecting && S.connectBtnBusy]}
|
||||||
|
onPress={handleConnect}
|
||||||
|
disabled={connecting}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{connecting
|
||||||
|
? <ActivityIndicator color={Colors.blue.fg} />
|
||||||
|
: <Text style={S.connectBtnTxt}>连 接 设 备</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={S.connectedRow}>
|
||||||
|
<View style={S.connectedLeft}>
|
||||||
|
<View style={S.greenDot} />
|
||||||
|
<Text style={S.connectedTxt}>已连接 {host}:{port}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect}>
|
||||||
|
<Text style={S.disconnectTxt}>断 开</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
<View style={S.logBox}>
|
||||||
|
<Text style={S.logHeader}>● 连接日志</Text>
|
||||||
|
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
|
||||||
|
{logs.map((l, i) => (
|
||||||
|
<Text key={i} style={[S.logLine, i === 0 && S.logLineLatest]}>{l}</Text>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
33
src/design/tokens.ts
Normal file
33
src/design/tokens.ts
Normal file
@ -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;
|
||||||
@ -39,6 +39,8 @@ export function useDevice() {
|
|||||||
const ack = await deviceSetup(config);
|
const ack = await deviceSetup(config);
|
||||||
if (ack.result !== 0x01) {
|
if (ack.result !== 0x01) {
|
||||||
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
||||||
|
} else {
|
||||||
|
useDeviceStore.getState().setConfigDirty(false);
|
||||||
}
|
}
|
||||||
return ack;
|
return ack;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -75,13 +77,14 @@ export function useDevice() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
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 {
|
try {
|
||||||
await deviceStop();
|
await deviceStop();
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
Alert.alert('停止超时', e.message);
|
// best-effort — UI already reflects idle
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useDataStore } from '../stores/dataStore';
|
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<number, number> = {
|
||||||
|
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 {
|
function downsample(data: Float64Array, targetLen: number): Float64Array {
|
||||||
if (data.length <= targetLen) return data;
|
if (data.length <= targetLen) return data;
|
||||||
const ratio = data.length / targetLen;
|
const ratio = data.length / targetLen;
|
||||||
@ -14,10 +23,7 @@ function downsample(data: Float64Array, targetLen: number): Float64Array {
|
|||||||
let maxAbs = 0;
|
let maxAbs = 0;
|
||||||
let maxVal = 0;
|
let maxVal = 0;
|
||||||
for (let j = start; j < end; j++) {
|
for (let j = start; j < end; j++) {
|
||||||
if (Math.abs(data[j]) > maxAbs) {
|
if (Math.abs(data[j]) > maxAbs) { maxAbs = Math.abs(data[j]); maxVal = data[j]; }
|
||||||
maxAbs = Math.abs(data[j]);
|
|
||||||
maxVal = data[j];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out[i] = maxVal;
|
out[i] = maxVal;
|
||||||
}
|
}
|
||||||
@ -25,58 +31,80 @@ function downsample(data: Float64Array, targetLen: number): Float64Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WaveformData {
|
export interface WaveformData {
|
||||||
channels: Float64Array[]; // downsampled μV data per channel
|
channels: Float64Array[]; // downsampled μV per channel
|
||||||
timeMs: Float64Array; // time axis in ms
|
timeMs: Float64Array; // time axis in ms (physical)
|
||||||
minLogUV: number; // log10 of min absolute non-zero value
|
totalTimeMs: number; // total window duration in ms
|
||||||
maxLogUV: number; // log10 of max absolute value
|
minLogUV: number; // log10(min |value|) for log scale
|
||||||
sampleDepth: number; // original samples per channel
|
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 useWaveform(visibleChannels: boolean[]): WaveformData | null {
|
export function computeWaveformData(
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
frame: MeasurementFrame,
|
||||||
|
visibleChannels: boolean[],
|
||||||
return useMemo(() => {
|
sampleFreqCode: number,
|
||||||
if (!frame) return null;
|
): WaveformData | null {
|
||||||
|
const { adcUV } = frame;
|
||||||
const { adcUV, meta } = frame;
|
|
||||||
const sampleDepth = adcUV[0]?.length ?? 0;
|
const sampleDepth = adcUV[0]?.length ?? 0;
|
||||||
if (sampleDepth === 0) return null;
|
if (sampleDepth === 0) return null;
|
||||||
|
|
||||||
// Build time axis in ms using sample freq (approximation)
|
const sampleHz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
|
||||||
// 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 displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
|
||||||
|
|
||||||
|
// Physical time axis
|
||||||
const timeMs = new Float64Array(displayLen);
|
const timeMs = new Float64Array(displayLen);
|
||||||
for (let i = 0; i < displayLen; i++) {
|
for (let i = 0; i < displayLen; i++) {
|
||||||
timeMs[i] = i / displayLen; // normalised 0–1 (multiply by period in ms externally)
|
timeMs[i] = (i / sampleHz) * 1000;
|
||||||
}
|
}
|
||||||
|
const totalTimeMs = ((displayLen - 1) / sampleHz) * 1000;
|
||||||
|
|
||||||
let globalMin = Infinity;
|
let logMin = Infinity;
|
||||||
let globalMax = -Infinity;
|
let logMax = -Infinity;
|
||||||
|
let linMin = Infinity;
|
||||||
|
let linMax = -Infinity;
|
||||||
|
|
||||||
const channels: Float64Array[] = adcUV.map((ch, idx) => {
|
const channels: Float64Array[] = adcUV.map((ch, idx) => {
|
||||||
if (!visibleChannels[idx]) return new Float64Array(displayLen);
|
if (!visibleChannels[idx]) return new Float64Array(displayLen);
|
||||||
const ds = downsample(ch, displayLen);
|
const ds = downsample(ch, displayLen);
|
||||||
for (let i = 0; i < ds.length; i++) {
|
for (let i = 0; i < ds.length; i++) {
|
||||||
const absV = Math.abs(ds[i]);
|
const v = ds[i];
|
||||||
|
const absV = Math.abs(v);
|
||||||
if (absV > 1e-9) {
|
if (absV > 1e-9) {
|
||||||
globalMin = Math.min(globalMin, absV);
|
logMin = Math.min(logMin, absV);
|
||||||
globalMax = Math.max(globalMax, absV);
|
logMax = Math.max(logMax, absV);
|
||||||
}
|
}
|
||||||
|
linMin = Math.min(linMin, v);
|
||||||
|
linMax = Math.max(linMax, v);
|
||||||
}
|
}
|
||||||
return ds;
|
return ds;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isFinite(globalMin)) globalMin = 1e-3;
|
if (!isFinite(logMin)) logMin = 1e-3;
|
||||||
if (!isFinite(globalMax)) globalMax = 1e6;
|
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 {
|
return {
|
||||||
channels,
|
channels, timeMs, totalTimeMs,
|
||||||
timeMs,
|
minLogUV: Math.log10(logMin), maxLogUV: Math.log10(logMax),
|
||||||
minLogUV: Math.log10(globalMin),
|
minUV: linMin, maxUV: linMax,
|
||||||
maxLogUV: Math.log10(globalMax),
|
|
||||||
sampleDepth,
|
sampleDepth,
|
||||||
};
|
};
|
||||||
}, [frame, visibleChannels]);
|
}
|
||||||
|
|
||||||
|
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
|
||||||
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
|
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!frame) return null;
|
||||||
|
return computeWaveformData(frame, visibleChannels, sampleFreqCode);
|
||||||
|
}, [frame, visibleChannels, sampleFreqCode]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,8 @@ export interface MeasurementFrame {
|
|||||||
adcUV: Float64Array[];
|
adcUV: Float64Array[];
|
||||||
accNum: number; // stack count at time of reception
|
accNum: number; // stack count at time of reception
|
||||||
gain: number; // gain multiplier at time of reception
|
gain: number; // gain multiplier at time of reception
|
||||||
timestamp: number;// local ms timestamp when received
|
sampleFreqCode:number; // ADC sample frequency code (from SetupConfig)
|
||||||
|
timestamp: number; // local ms timestamp when received
|
||||||
frameId: number; // sequential frame counter
|
frameId: number; // sequential frame counter
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,3 +82,13 @@ export type ConnectionStatus =
|
|||||||
|
|
||||||
// Device running state
|
// Device running state
|
||||||
export type DeviceStatus = 'idle' | 'running' | 'single';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
203
src/services/BinLoader.ts
Normal file
203
src/services/BinLoader.ts
Normal file
@ -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<Uint8Array | null> {
|
||||||
|
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<void> {
|
||||||
|
await FileSystem.writeAsStringAsync(path, u8ToBase64(data), {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBinFile(path: string, frame: MeasurementFrame): Promise<void> {
|
||||||
|
await writeFileBytes(path, encodeBin(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBinFile(path: string): Promise<LoadedBin | null> {
|
||||||
|
const bytes = await readFileBytes(path);
|
||||||
|
return bytes ? decodeBin(bytes) : null;
|
||||||
|
}
|
||||||
@ -17,8 +17,6 @@ import { useConnectionStore } from '../stores/connectionStore';
|
|||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import { useDataStore } from '../stores/dataStore';
|
import { useDataStore } from '../stores/dataStore';
|
||||||
|
|
||||||
let frameCounter = 0;
|
|
||||||
|
|
||||||
// Pending ACK promise resolver keyed by func code
|
// Pending ACK promise resolver keyed by func code
|
||||||
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
||||||
|
|
||||||
@ -33,7 +31,7 @@ export function handleIncomingFrame(frame: RawFrame) {
|
|||||||
const dataStore = useDataStore.getState();
|
const dataStore = useDataStore.getState();
|
||||||
|
|
||||||
switch (func) {
|
switch (func) {
|
||||||
// ── ACK responses ──────────────────────────────────────────────
|
// ── ACK responses ──────────────────────────────────────────────────
|
||||||
case FuncCode.SETUP_ACK:
|
case FuncCode.SETUP_ACK:
|
||||||
case FuncCode.CONTINUOUS_ACK:
|
case FuncCode.CONTINUOUS_ACK:
|
||||||
case FuncCode.SINGLE_ACK:
|
case FuncCode.SINGLE_ACK:
|
||||||
@ -97,6 +95,10 @@ function processMeasurementPayload(
|
|||||||
devStore: ReturnType<typeof useDeviceStore.getState>,
|
devStore: ReturnType<typeof useDeviceStore.getState>,
|
||||||
dataStore: ReturnType<typeof useDataStore.getState>,
|
dataStore: ReturnType<typeof useDataStore.getState>,
|
||||||
) {
|
) {
|
||||||
|
// 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);
|
const meta = parseMetadata(payload);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
|
||||||
@ -110,11 +112,26 @@ function processMeasurementPayload(
|
|||||||
adcUV,
|
adcUV,
|
||||||
accNum,
|
accNum,
|
||||||
gain: cfg.ampRatio,
|
gain: cfg.ampRatio,
|
||||||
|
sampleFreqCode: cfg.sampleFreq,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
frameId: frameCounter++,
|
frameId: dataStore.nextFrameId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
dataStore.addFrame(measurementFrame);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
497
src/services/StorageService.ts
Normal file
497
src/services/StorageService.ts
Normal file
@ -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<SQLite.SQLiteDatabase> {
|
||||||
|
if (!_db) {
|
||||||
|
_db = await SQLite.openDatabaseAsync('tem.db');
|
||||||
|
await _db.execAsync(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
project_id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS frames (
|
||||||
|
frame_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ts INTEGER,
|
||||||
|
utc INTEGER, lon REAL, lat REAL, alt REAL,
|
||||||
|
ch_num INTEGER, acc_num INTEGER, gain REAL,
|
||||||
|
gps_st INTEGER, sd_st INTEGER, amp_ratio INTEGER,
|
||||||
|
current INTEGER, temp INTEGER, batt INTEGER,
|
||||||
|
roll REAL, pitch REAL, yaw REAL, src_mode INTEGER,
|
||||||
|
bin_path TEXT,
|
||||||
|
PRIMARY KEY (frame_id, session_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<ProjectInfo[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.getAllAsync<{
|
||||||
|
project_id: string; name: string; created_at: number; line_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT p.project_id, p.name, p.created_at,
|
||||||
|
COUNT(s.session_id) AS line_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN sessions s ON s.project_id = p.project_id
|
||||||
|
GROUP BY p.project_id
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
);
|
||||||
|
return rows.map(r => ({
|
||||||
|
projectId: r.project_id,
|
||||||
|
name: r.name,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
lineCount: r.line_count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameProject(projectId: string, name: string): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.runAsync('UPDATE projects SET name = ? WHERE project_id = ?', [name.trim(), projectId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(projectId: string): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ensureSession(sessionId: string, projectId?: string | null): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.runAsync(
|
||||||
|
'INSERT OR IGNORE INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
|
||||||
|
[sessionId, Date.now(), projectId ?? null],
|
||||||
|
);
|
||||||
|
if (projectId) {
|
||||||
|
await d.runAsync(
|
||||||
|
'UPDATE sessions SET project_id = ? WHERE session_id = ? AND project_id IS NULL',
|
||||||
|
[projectId, sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<SessionInfo[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.getAllAsync<{
|
||||||
|
session_id: string; created_at: number; frame_count: number; project_id: string | null;
|
||||||
|
}>(
|
||||||
|
'SELECT session_id, created_at, frame_count, project_id FROM sessions ORDER BY created_at DESC',
|
||||||
|
);
|
||||||
|
return rows.map(r => ({
|
||||||
|
sessionId: r.session_id,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
frameCount: r.frame_count,
|
||||||
|
projectId: r.project_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessionsByProject(projectId: string): Promise<SessionInfo[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.getAllAsync<{
|
||||||
|
session_id: string; created_at: number; frame_count: number;
|
||||||
|
}>(
|
||||||
|
'SELECT session_id, created_at, frame_count FROM sessions WHERE project_id = ? ORDER BY created_at DESC',
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
return rows.map(r => ({
|
||||||
|
sessionId: r.session_id,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
frameCount: r.frame_count,
|
||||||
|
projectId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignSessionToProject(sessionId: string, projectId: string | null): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.runAsync('UPDATE sessions SET project_id = ? WHERE session_id = ?', [projectId, sessionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
try {
|
||||||
|
const files = await FileSystem.readDirectoryAsync(DATA_DIR);
|
||||||
|
await Promise.all(
|
||||||
|
files
|
||||||
|
.filter(f => f.startsWith(sessionId))
|
||||||
|
.map(f => FileSystem.deleteAsync(DATA_DIR + f, { idempotent: true })),
|
||||||
|
);
|
||||||
|
} catch { /* directory may not exist */ }
|
||||||
|
await d.runAsync('DELETE FROM frames WHERE session_id = ?', [sessionId]);
|
||||||
|
await d.runAsync('DELETE FROM sessions WHERE session_id = ?', [sessionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame persistence ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function persistFrame(frame: MeasurementFrame, sessionId: string): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
const m = frame.meta;
|
||||||
|
|
||||||
|
let binPath: string | null = null;
|
||||||
|
try {
|
||||||
|
const fname = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
|
||||||
|
binPath = DATA_DIR + fname;
|
||||||
|
await BinLoader.saveBinFile(binPath, frame);
|
||||||
|
} catch { binPath = null; }
|
||||||
|
|
||||||
|
await d.runAsync(
|
||||||
|
`INSERT OR 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<PersistedFrame[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.getAllAsync<any>(
|
||||||
|
'SELECT * FROM frames WHERE session_id = ? ORDER BY frame_id DESC',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
return rows.map(r => ({
|
||||||
|
frameId: r.frame_id,
|
||||||
|
sessionId: r.session_id,
|
||||||
|
timestamp: r.ts,
|
||||||
|
accNum: r.acc_num,
|
||||||
|
gain: r.gain,
|
||||||
|
binPath: r.bin_path,
|
||||||
|
meta: {
|
||||||
|
devId: 0, utc: r.utc,
|
||||||
|
longitude: r.lon, latitude: r.lat, altitude: r.alt, height: 0,
|
||||||
|
sdStatus: r.sd_st, gpsStatus: r.gps_st, ampRatio: r.amp_ratio,
|
||||||
|
roll: r.roll, pitch: r.pitch, yaw: r.yaw,
|
||||||
|
channelNum: r.ch_num, current: r.current,
|
||||||
|
temperature: r.temp, batteryVolt: r.batt, sourceMode: r.src_mode,
|
||||||
|
} as MeasurementMeta,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFrame(frameId: number, sessionId: string): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
const row = await d.getFirstAsync<{ bin_path: string | null }>(
|
||||||
|
'SELECT bin_path FROM frames WHERE frame_id = ? AND session_id = ?',
|
||||||
|
[frameId, sessionId],
|
||||||
|
);
|
||||||
|
if (row?.bin_path) {
|
||||||
|
try { await FileSystem.deleteAsync(row.bin_path, { idempotent: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
await d.runAsync('DELETE FROM frames WHERE frame_id = ? AND session_id = ?', [frameId, sessionId]);
|
||||||
|
await d.runAsync('UPDATE sessions SET frame_count = MAX(0, frame_count - 1) WHERE session_id = ?', [sessionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaxFrameId(sessionId: string): Promise<number> {
|
||||||
|
const d = await getDb();
|
||||||
|
const row = await d.getFirstAsync<{ max_id: number | null }>(
|
||||||
|
'SELECT MAX(frame_id) AS max_id FROM frames WHERE session_id = ?',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
return row?.max_id ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function exportSessionMetaCsv(sessionId: string): Promise<string> {
|
||||||
|
const frames = await loadSessionFrames(sessionId);
|
||||||
|
if (frames.length === 0) throw new Error('会话无数据');
|
||||||
|
|
||||||
|
const { formatUtc } = await import('../utils/format');
|
||||||
|
|
||||||
|
const maxCh = frames.reduce((m, f) => Math.max(m, f.meta.channelNum), 0);
|
||||||
|
const chHeaders = Array.from({ length: maxCh }, (_, i) => `CH${i + 1}_peak_uV`).join(',');
|
||||||
|
const header = `FrameID,UTC,Longitude,Latitude,Altitude,GPS,SD,AmpRatio,Current,Temp,Battery,Roll,Pitch,Yaw,${chHeaders}`;
|
||||||
|
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (const f of frames) {
|
||||||
|
const m = f.meta;
|
||||||
|
let peaks: string[] = Array(maxCh).fill('');
|
||||||
|
if (f.binPath) {
|
||||||
|
const bin = await BinLoader.loadBinFile(f.binPath);
|
||||||
|
if (bin) {
|
||||||
|
peaks = Array.from({ length: maxCh }, (_, i) => {
|
||||||
|
const ch = bin.adcUV[i];
|
||||||
|
if (!ch) return '';
|
||||||
|
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||||
|
return pk.toFixed(4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push([
|
||||||
|
f.frameId, formatUtc(m.utc),
|
||||||
|
m.longitude.toFixed(7), m.latitude.toFixed(7), m.altitude.toFixed(2),
|
||||||
|
m.gpsStatus, m.sdStatus, m.ampRatio, m.current, m.temperature, m.batteryVolt,
|
||||||
|
m.roll.toFixed(2), m.pitch.toFixed(2), m.yaw.toFixed(2),
|
||||||
|
...peaks,
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = [header, ...rows].join('\n');
|
||||||
|
await ensureDataDir();
|
||||||
|
const path = DATA_DIR + `TEM_${sessionId}.csv`;
|
||||||
|
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── .tem export ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function exportProject(
|
||||||
|
projectId: string,
|
||||||
|
deviceConfig: SetupConfig,
|
||||||
|
gateConfig: GateConfig,
|
||||||
|
onProgress?: (current: number, total: number) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
const d = await getDb();
|
||||||
|
|
||||||
|
const proj = await d.getFirstAsync<{ name: string; created_at: number }>(
|
||||||
|
'SELECT name, created_at FROM projects WHERE project_id = ?',
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
if (!proj) throw new Error('工程不存在');
|
||||||
|
|
||||||
|
const sessions = await listSessionsByProject(projectId);
|
||||||
|
|
||||||
|
// Collect all frames + bin bytes
|
||||||
|
const bins: Record<string, Uint8Array> = {};
|
||||||
|
const sessionManifests: TemBundle.SessionManifest[] = [];
|
||||||
|
let totalFrames = sessions.reduce((s, x) => s + x.frameCount, 0);
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const frames = await loadSessionFrames(session.sessionId);
|
||||||
|
for (const f of frames) {
|
||||||
|
if (f.binPath) {
|
||||||
|
const bytes = await BinLoader.readFileBytes(f.binPath);
|
||||||
|
if (bytes) {
|
||||||
|
const relPath = TemBundle.bundleBinPath(session.sessionId, f.frameId);
|
||||||
|
bins[relPath] = bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress?.(++done, totalFrames);
|
||||||
|
}
|
||||||
|
sessionManifests.push({
|
||||||
|
id: session.sessionId,
|
||||||
|
created_at: session.createdAt,
|
||||||
|
frame_count: session.frameCount,
|
||||||
|
device_config: deviceConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: TemBundle.ProjectManifest = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
project: { id: projectId, name: proj.name, created_at: proj.created_at },
|
||||||
|
device_config: deviceConfig,
|
||||||
|
gate_config: gateConfig,
|
||||||
|
sessions: sessionManifests,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bundleBytes = await TemBundle.packBundle(manifest, bins);
|
||||||
|
|
||||||
|
await ensureDataDir();
|
||||||
|
const 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<ImportResult> {
|
||||||
|
await ensureDataDir();
|
||||||
|
const d = await getDb();
|
||||||
|
|
||||||
|
// Copy to temp path (handles content:// URIs on Android)
|
||||||
|
const tmpPath = DATA_DIR + `tmp_import_${Date.now()}.tem`;
|
||||||
|
try {
|
||||||
|
await FileSystem.copyAsync({ from: bundleUri, to: tmpPath });
|
||||||
|
} catch {
|
||||||
|
// URI may already be a file:// path — try reading directly
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundleBytes: Uint8Array | null = null;
|
||||||
|
try {
|
||||||
|
bundleBytes = await BinLoader.readFileBytes(tmpPath);
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
if (!bundleBytes) {
|
||||||
|
bundleBytes = await BinLoader.readFileBytes(bundleUri);
|
||||||
|
}
|
||||||
|
if (!bundleBytes) throw new Error('无法读取 .tem 文件');
|
||||||
|
|
||||||
|
// Clean up temp file (don't await — non-critical)
|
||||||
|
FileSystem.deleteAsync(tmpPath, { idempotent: true }).catch(() => {});
|
||||||
|
|
||||||
|
const { manifest, bins } = await TemBundle.parseBundle(bundleBytes);
|
||||||
|
|
||||||
|
// Allocate new project ID to avoid collision
|
||||||
|
const newProjectId = generateProjectId();
|
||||||
|
await d.runAsync(
|
||||||
|
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
|
||||||
|
[newProjectId, manifest.project.name, manifest.project.created_at],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalFrames = Object.keys(bins).length;
|
||||||
|
let done = 0;
|
||||||
|
let importedFrames = 0;
|
||||||
|
|
||||||
|
for (const session of manifest.sessions) {
|
||||||
|
// Use original session ID if not already in DB; otherwise suffix with timestamp
|
||||||
|
const existing = await d.getFirstAsync<{ session_id: string }>(
|
||||||
|
'SELECT session_id FROM sessions WHERE session_id = ?',
|
||||||
|
[session.id],
|
||||||
|
);
|
||||||
|
const newSessionId = existing ? `${session.id}_${Date.now()}` : session.id;
|
||||||
|
|
||||||
|
await d.runAsync(
|
||||||
|
'INSERT INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
|
||||||
|
[newSessionId, session.created_at, newProjectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sessionFrameCount = 0;
|
||||||
|
for (const [relPath, binBytes] of Object.entries(bins)) {
|
||||||
|
const parsed = TemBundle.parseBinPath(relPath);
|
||||||
|
if (!parsed || parsed.sessionId !== session.id) continue;
|
||||||
|
|
||||||
|
const { frameId } = parsed;
|
||||||
|
const meta = BinLoader.decodeBin(binBytes);
|
||||||
|
if (!meta) { onProgress?.(++done, totalFrames); continue; }
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
101
src/services/TemBundle.ts
Normal file
101
src/services/TemBundle.ts
Normal file
@ -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<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
zip(files, (err, data) => (err ? reject(err) : resolve(data)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseUnzip(data: Uint8Array): Promise<Record<string, Uint8Array>> {
|
||||||
|
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<string, Uint8Array>,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
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<string, Uint8Array>;
|
||||||
|
}> {
|
||||||
|
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<string, Uint8Array> = {};
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@ -1,25 +1,42 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
|
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
|
||||||
import type { ConnectionStatus } from '../protocol/types';
|
import type { ConnectionStatus } from '../protocol/types';
|
||||||
|
import { fileStorage } from '../utils/storage';
|
||||||
|
|
||||||
interface ConnectionState {
|
interface ConnectionState {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
|
modalVisible: boolean;
|
||||||
setStatus: (s: ConnectionStatus) => void;
|
setStatus: (s: ConnectionStatus) => void;
|
||||||
setHost: (h: string) => void;
|
setHost: (h: string) => void;
|
||||||
setPort: (p: number) => void;
|
setPort: (p: number) => void;
|
||||||
setError: (e: string) => void;
|
setError: (e: string) => void;
|
||||||
|
showModal: () => void;
|
||||||
|
hideModal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useConnectionStore = create<ConnectionState>((set) => ({
|
export const useConnectionStore = create<ConnectionState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
host: DEFAULT_DEVICE_IP,
|
host: DEFAULT_DEVICE_IP,
|
||||||
port: DEFAULT_TCP_PORT,
|
port: DEFAULT_TCP_PORT,
|
||||||
lastError: '',
|
lastError: '',
|
||||||
|
modalVisible: false,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setHost: (host) => set({ host }),
|
setHost: (host) => set({ host }),
|
||||||
setPort: (port) => set({ port }),
|
setPort: (port) => set({ port }),
|
||||||
setError: (lastError) => set({ lastError, status: 'error' }),
|
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 }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@ -1,29 +1,114 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { MeasurementFrame } from '../protocol/types';
|
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 {
|
interface DataState {
|
||||||
currentFrame: MeasurementFrame | null;
|
currentFrame: MeasurementFrame | null;
|
||||||
history: MeasurementFrame[]; // newest first
|
history: MeasurementFrame[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
_frameCounter: number;
|
||||||
|
nextFrameId: () => number;
|
||||||
addFrame: (frame: MeasurementFrame) => void;
|
addFrame: (frame: MeasurementFrame) => void;
|
||||||
|
deleteFrame: (frameId: number) => void;
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
newSession: () => void;
|
/** Start a new line (session), optionally under a project. */
|
||||||
|
newSession: (projectId?: string | null) => Promise<void>;
|
||||||
|
/** 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<void>;
|
||||||
|
/** Change the project association of the current session. */
|
||||||
|
setProject: (projectId: string | null) => Promise<void>;
|
||||||
|
init: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDataStore = create<DataState>((set) => ({
|
export const useDataStore = create<DataState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
currentFrame: null,
|
currentFrame: null,
|
||||||
history: [],
|
history: [],
|
||||||
sessionId: generateSessionId(),
|
sessionId: generateSessionId(),
|
||||||
addFrame: (frame) =>
|
projectId: null,
|
||||||
|
_frameCounter: 0,
|
||||||
|
|
||||||
|
nextFrameId: () => {
|
||||||
|
const id = get()._frameCounter;
|
||||||
|
set((s) => ({ _frameCounter: s._frameCounter + 1 }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
addFrame: (frame) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
currentFrame: frame,
|
currentFrame: frame,
|
||||||
history: [frame, ...s.history].slice(0, MAX_HISTORY),
|
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 }),
|
clearHistory: () => set({ history: [], currentFrame: null }),
|
||||||
newSession: () => set({ sessionId: generateSessionId(), 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 {
|
function generateSessionId(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { create } from 'zustand';
|
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 { 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 = {
|
const DEFAULT_CONFIG: SetupConfig = {
|
||||||
channelNum: 3,
|
channelNum: 3,
|
||||||
@ -21,16 +30,23 @@ const DEFAULT_CONFIG: SetupConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface DeviceState {
|
interface DeviceState {
|
||||||
|
// ── Device config (persisted) ──────────────────────────────────────────────
|
||||||
config: SetupConfig;
|
config: SetupConfig;
|
||||||
|
gateConfig: GateConfig;
|
||||||
|
configDirty: boolean; // true when config changed but not yet sent to device
|
||||||
|
// ── Runtime status (not persisted) ────────────────────────────────────────
|
||||||
deviceStatus: DeviceStatus;
|
deviceStatus: DeviceStatus;
|
||||||
batteryVolt: number; // raw ADC
|
// ── Telemetry (not persisted) ─────────────────────────────────────────────
|
||||||
temperature: number; // raw ADC
|
batteryVolt: number;
|
||||||
|
temperature: number;
|
||||||
gpsStatus: number;
|
gpsStatus: number;
|
||||||
sdStatus: number;
|
sdStatus: number;
|
||||||
frameId: number; // last received frame ID
|
frameId: number;
|
||||||
accCount: number; // stacking progress
|
accCount: number;
|
||||||
setConfig: (cfg: SetupConfig) => void;
|
setConfig: (cfg: SetupConfig) => void;
|
||||||
updateConfig: (partial: Partial<SetupConfig>) => void;
|
updateConfig: (partial: Partial<SetupConfig>) => void;
|
||||||
|
setGateConfig: (cfg: GateConfig) => void;
|
||||||
|
setConfigDirty: (v: boolean) => void;
|
||||||
setDeviceStatus: (s: DeviceStatus) => void;
|
setDeviceStatus: (s: DeviceStatus) => void;
|
||||||
updateTelemetry: (data: {
|
updateTelemetry: (data: {
|
||||||
batteryVolt?: number;
|
batteryVolt?: number;
|
||||||
@ -42,8 +58,12 @@ interface DeviceState {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDeviceStore = create<DeviceState>((set) => ({
|
export const useDeviceStore = create<DeviceState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
config: DEFAULT_CONFIG,
|
config: DEFAULT_CONFIG,
|
||||||
|
gateConfig: DEFAULT_GATE_CONFIG,
|
||||||
|
configDirty: false,
|
||||||
deviceStatus: 'idle',
|
deviceStatus: 'idle',
|
||||||
batteryVolt: 0,
|
batteryVolt: 0,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
@ -51,9 +71,17 @@ export const useDeviceStore = create<DeviceState>((set) => ({
|
|||||||
sdStatus: 0,
|
sdStatus: 0,
|
||||||
frameId: 0,
|
frameId: 0,
|
||||||
accCount: 0,
|
accCount: 0,
|
||||||
setConfig: (config) => set({ config }),
|
setConfig: (config) => set({ config, configDirty: true }),
|
||||||
updateConfig: (partial) =>
|
updateConfig: (partial) => set((s) => ({ config: { ...s.config, ...partial }, configDirty: true })),
|
||||||
set((s) => ({ config: { ...s.config, ...partial } })),
|
setGateConfig: (gateConfig) => set({ gateConfig }),
|
||||||
|
setConfigDirty: (configDirty) => set({ configDirty }),
|
||||||
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
|
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
|
||||||
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
|
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
|
||||||
}));
|
}),
|
||||||
|
{
|
||||||
|
name: 'device-config',
|
||||||
|
storage: createJSONStorage(() => fileStorage),
|
||||||
|
partialize: (state) => ({ config: state.config, gateConfig: state.gateConfig }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
32
src/utils/storage.ts
Normal file
32
src/utils/storage.ts
Normal file
@ -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<string | null> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
try {
|
||||||
|
const path = `${BASE}zs_${name}.json`;
|
||||||
|
await FileSystem.deleteAsync(path, { idempotent: true });
|
||||||
|
} catch { /* silent */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
370
tools/mock_device.py
Normal file
370
tools/mock_device.py
Normal file
@ -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('<BBI', FRAME_FLAG, func, len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
def build_ack(func_ack: int, ok: bool = True, reason: str = '') -> 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('<I', meta, 1, int(time.time())) # utc
|
||||||
|
struct.pack_into('<d', meta, 5, 116.3972) # longitude(北京)
|
||||||
|
struct.pack_into('<d', meta, 13, 39.9086) # latitude
|
||||||
|
struct.pack_into('<f', meta, 21, 50.0) # altitude
|
||||||
|
struct.pack_into('<f', meta, 25, 1.0) # height
|
||||||
|
meta[29] = 0x11 # sdStatus=1, gpsStatus=1
|
||||||
|
meta[30] = amp_idx & 0xFF # ampRatio
|
||||||
|
struct.pack_into('<f', meta, 31, 0.0) # roll
|
||||||
|
struct.pack_into('<f', meta, 35, 2.5) # pitch(模拟轻微倾斜)
|
||||||
|
struct.pack_into('<f', meta, 39, 0.0) # yaw
|
||||||
|
meta[43] = ch_num & 0xFF # channelNum
|
||||||
|
struct.pack_into('<I', meta, 44, 1000) # current
|
||||||
|
struct.pack_into('<H', meta, 48, 2100) # temperature(raw ADC ~25°C)
|
||||||
|
struct.pack_into('<H', meta, 50, 3760) # batteryVolt(raw ~3.7V)
|
||||||
|
meta[52] = cfg.get('sourceMode', 0) & 0xFF
|
||||||
|
|
||||||
|
# ADC 采样数据:每通道不同频率的正弦信号
|
||||||
|
SIGNAL_UV = 5e5 # 信号幅度 0.5V 换算为 μV
|
||||||
|
NOISE_RATIO = 0.02 # 2% 噪声
|
||||||
|
|
||||||
|
# 各通道信号频率(单位 Hz,相对于 sampleDepth 归一化)
|
||||||
|
CH_FREQS = [1.0, 2.0, 3.0, 5.0, 7.0, 11.0]
|
||||||
|
|
||||||
|
adc_bytes = bytearray(ch_num * depth * 4)
|
||||||
|
for ch in range(ch_num):
|
||||||
|
freq = CH_FREQS[ch % len(CH_FREQS)]
|
||||||
|
for s in range(depth):
|
||||||
|
t_norm = s / depth + phase * freq
|
||||||
|
uv = SIGNAL_UV * math.sin(2 * math.pi * t_norm)
|
||||||
|
uv += random.gauss(0, SIGNAL_UV * NOISE_RATIO)
|
||||||
|
raw = int(uv * gain * acc_num)
|
||||||
|
raw = max(-0x80000000, min(0x7FFFFFFF, raw))
|
||||||
|
struct.pack_into('<i', adc_bytes, (ch * depth + s) * 4, raw)
|
||||||
|
|
||||||
|
return build_frame(FC.DATA_ACK, bytes(meta) + bytes(adc_bytes))
|
||||||
|
|
||||||
|
|
||||||
|
# ── TCP 帧解析器 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FrameParser:
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = bytearray()
|
||||||
|
|
||||||
|
def feed(self, data: bytes):
|
||||||
|
self.buf += data
|
||||||
|
frames = []
|
||||||
|
while len(self.buf) >= 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('<I', self.buf, 6)[0]
|
||||||
|
if payload_len > 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('<H', payload, 3)[0]
|
||||||
|
self.cfg['sampleDepth'] = depth if depth > 0 else 256
|
||||||
|
acc_flags = struct.unpack_from('<H', payload, 5)[0]
|
||||||
|
acc_num = (acc_flags >> 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()
|
||||||
875
tools/mock_device_gui.py
Normal file
875
tools/mock_device_gui.py
Normal file
@ -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('<BBI', FRAME_FLAG, func, len(payload)) + payload
|
||||||
|
|
||||||
|
def build_ack(func_ack: int, ok: bool = True) -> 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('<I', meta, 1, int(time.time()))
|
||||||
|
struct.pack_into('<d', meta, 5, snap['longitude'])
|
||||||
|
struct.pack_into('<d', meta, 13, snap['latitude'])
|
||||||
|
struct.pack_into('<f', meta, 21, snap['altitude'])
|
||||||
|
struct.pack_into('<f', meta, 25, 1.0)
|
||||||
|
meta[29] = (snap['sdStatus'] & 0xF) | ((snap['gpsStatus'] & 0xF) << 4)
|
||||||
|
meta[30] = amp & 0xFF
|
||||||
|
struct.pack_into('<f', meta, 31, snap['roll'])
|
||||||
|
struct.pack_into('<f', meta, 35, snap['pitch'])
|
||||||
|
struct.pack_into('<f', meta, 39, snap['yaw'])
|
||||||
|
meta[43] = ch & 0xFF
|
||||||
|
struct.pack_into('<I', meta, 44, 1000)
|
||||||
|
struct.pack_into('<H', meta, 48, snap['tempRaw'])
|
||||||
|
struct.pack_into('<H', meta, 50, snap['battRaw'])
|
||||||
|
meta[52] = snap['sourceMode'] & 0xFF
|
||||||
|
|
||||||
|
adc = bytearray(ch * depth * 4)
|
||||||
|
for c in range(ch):
|
||||||
|
for s in range(depth):
|
||||||
|
v = gen_signal(sig, c, s, depth, phase)
|
||||||
|
v += random.gauss(0, noise) if noise > 0 else 0
|
||||||
|
raw = int(v * sig_uv * gain * acc)
|
||||||
|
raw = max(-0x80000000, min(0x7FFFFFFF, raw))
|
||||||
|
struct.pack_into('<i', adc, (c * depth + s) * 4, raw)
|
||||||
|
|
||||||
|
payload = bytes(meta) + bytes(adc)
|
||||||
|
return _pack_frame(FC.DATA_ACK, payload)
|
||||||
|
|
||||||
|
# ── 帧解析器 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FrameParser:
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = bytearray()
|
||||||
|
|
||||||
|
def feed(self, data: bytes):
|
||||||
|
self.buf += data
|
||||||
|
frames = []
|
||||||
|
while len(self.buf) >= 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('<I', self.buf, 6)[0]
|
||||||
|
if plen > 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('<H', payload, 3)[0]
|
||||||
|
self.cfg.sample_depth = depth if depth > 0 else 256
|
||||||
|
acc_flags = struct.unpack_from('<H', payload, 5)[0]
|
||||||
|
acc = (acc_flags >> 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('<<ComboboxSelected>>', 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('<<ComboboxSelected>>', 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('<<ComboboxSelected>>', 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('<Configure>', 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user