This commit is contained in:
zhoujie 2026-06-19 22:08:10 +08:00
parent 091b358705
commit 84590d5477
37 changed files with 5848 additions and 904 deletions

4
.gitignore vendored
View File

@ -39,3 +39,7 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
# python
*.pyc
__pycache__/

View File

@ -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>
); );
} }

View File

@ -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' },
}); });

View File

@ -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%',
},
});

View File

@ -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
View 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
View 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' },
});

View File

@ -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' },
}); });

View File

@ -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
View 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 },
});

View File

@ -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' },
}); });

View File

@ -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>
); );
} }

View File

@ -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
View File

@ -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",

View File

@ -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
View 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
View 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

View File

@ -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 },
}); });

View File

@ -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' },
}); });

View File

@ -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 },
}); });

View File

@ -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,
},
}); });

View 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',
},
});

View 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
View 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;

View File

@ -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);
} }
}, []); }, []);

View File

@ -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 01 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 01 (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]);
} }

View File

@ -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
View 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;
}

View File

@ -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 ──────────────────────────────────────────────────────────────

View 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
View 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`;
}

View File

@ -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 }),
},
),
);

View File

@ -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();

View File

@ -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
View 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
View 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) # temperatureraw ADC ~25°C
struct.pack_into('<H', meta, 50, 3760) # batteryVoltraw ~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
View 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()