This commit is contained in:
zhoujie 2026-06-06 22:13:00 +08:00
parent ed8682a1e2
commit 091b358705
27 changed files with 3001 additions and 102 deletions

View File

@ -1,22 +1,36 @@
{
"expo": {
"name": "TriloopTem_App",
"name": "TEM Receiver",
"slug": "TriloopTem_App",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "trilooptemapp",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "dark",
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.triloop.temreceiver",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "用于在地图上标注测点位置",
"NSLocationAlwaysUsageDescription": "用于野外作业时持续记录GPS轨迹"
}
},
"android": {
"package": "com.triloop.temreceiver",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundColor": "#0d0d0d",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
],
"predictiveBackGestureEnabled": false
},
"web": {
@ -31,7 +45,22 @@
{
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#0d0d0d"
}
],
"expo-sqlite",
"expo-sharing",
[
"expo-location",
{
"locationWhenInUsePermission": "用于在地图上标注测点位置",
"locationAlwaysPermission": "用于野外作业时持续记录GPS轨迹"
}
],
[
"react-native-maps",
{
"googleMapsApiKey": ""
}
]
],

View File

@ -1,67 +1,60 @@
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { SymbolView } from 'expo-symbols';
import { Link, Tabs } from 'expo-router';
import { Platform, Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
const ACTIVE = '#4a9eff';
const INACTIVE = '#555';
const BG = '#111111';
// SymbolView tintColor is typed as string but Tabs passes ColorValue — cast to avoid TS error
function TabIcon({ name, color }: { name: string; color: any }) {
return <SymbolView name={name as any} tintColor={color} size={22} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
headerShown: false,
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 10 },
}}
>
<Tabs.Screen
name="index"
name="control"
options={{
title: 'Tab One',
title: '控制台',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable style={{ marginRight: 15 }}>
{({ pressed }) => (
<SymbolView
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
size={25}
tintColor={Colors[colorScheme].text}
style={{ opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
),
}}
/>
<Tabs.Screen
name="two"
name="waveform"
options={{
title: 'Tab Two',
title: '波形',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
<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
name="records"
options={{
title: '记录',
tabBarIcon: ({ color }) => (
<TabIcon name={Platform.select({ ios: 'list.bullet', android: 'list', web: 'list' })!} color={color} />
),
}}
/>

150
app/(tabs)/control.tsx Normal file
View File

@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { ParamForm } from '../../src/components/ParamForm';
import { useDevice } from '../../src/hooks/useDevice';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { useDataStore } from '../../src/stores/dataStore';
export default function ControlScreen() {
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
const frame = useDataStore((s) => s.currentFrame);
const [configDirty, setConfigDirty] = useState(false);
const handleSetup = async () => {
await setup();
setConfigDirty(false);
};
if (!connected) {
return (
<View style={styles.notConnected}>
<Text style={styles.notConnectedText}></Text>
<TouchableOpacity style={styles.connectBtn} onPress={() => router.replace('/connect')}>
<Text style={styles.connectBtnText}></Text>
</TouchableOpacity>
</View>
);
}
const running = deviceStatus === 'running';
const canStart = !running && !busy;
const canStop = (running || deviceStatus === 'single') && !busy;
return (
<View style={styles.container}>
<DeviceStatusBar />
{/* Control buttons */}
<View style={styles.ctrlRow}>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
onPress={startContinuous}
disabled={!canStart}
>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}></Text>}
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
onPress={startSingle}
disabled={!canStart}
>
<Text style={styles.ctrlText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
onPress={stop}
disabled={!canStop}
>
<Text style={styles.ctrlText}></Text>
</TouchableOpacity>
</View>
{/* Last frame info */}
{frame && (
<View style={styles.frameInfo}>
<Text style={styles.frameText}>
#{frame.frameId} {frame.meta ? frame.accNum : '--'} {frame.meta?.channelNum ?? '-'}
</Text>
<Text style={styles.frameText}>
: {frame.meta?.current ?? '--'} : {frame.meta?.temperature ?? '--'}
</Text>
</View>
)}
{/* Param form */}
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
<View style={styles.formHeader}>
<Text style={styles.formTitle}></Text>
{configDirty && <Text style={styles.dirtyBadge}></Text>}
</View>
<ParamForm onAnyChange={() => setConfigDirty(true)} />
<TouchableOpacity
style={[styles.setupBtn, busy && styles.btnDisabled]}
onPress={handleSetup}
disabled={busy}
>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}> </Text>}
</TouchableOpacity>
<View style={{ height: 24 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
notConnectedText: { color: '#666', fontSize: 16 },
connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
connectBtnText: { color: '#fff', fontWeight: '600' },
ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
ctrlBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
btnStart: { backgroundColor: '#1a5c2a' },
btnSingle: { backgroundColor: '#1a3a5c' },
btnStop: { backgroundColor: '#5c1a1a' },
btnDisabled: { opacity: 0.4 },
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
frameInfo: {
marginHorizontal: 12,
backgroundColor: '#181818',
borderRadius: 8,
padding: 8,
marginBottom: 8,
},
frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
formScroll: { flex: 1 },
formHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 8,
},
formTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600' },
dirtyBadge: {
backgroundColor: '#5c3a0a',
color: '#ffaa00',
fontSize: 10,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
setupBtn: {
margin: 14,
backgroundColor: '#4a9eff',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
});

114
app/(tabs)/map.tsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
import MapView, { Marker, Polyline, type Region } from 'react-native-maps';
import { useDataStore } from '../../src/stores/dataStore';
import { formatCoord } from '../../src/utils/format';
export default function MapScreen() {
const history = useDataStore((s) => s.history);
const mapRef = useRef<MapView>(null);
const points = history
.filter((f) => f.meta && f.meta.latitude !== 0 && f.meta.longitude !== 0)
.map((f) => ({
latitude: f.meta!.latitude,
longitude: f.meta!.longitude,
frameId: f.frameId,
}))
.reverse(); // oldest first for polyline
const latest = points[points.length - 1];
const centerOnCurrent = () => {
if (!latest || !mapRef.current) return;
mapRef.current.animateToRegion(
{ latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.005, longitudeDelta: 0.005 },
600,
);
};
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={styles.map}
mapType="satellite"
showsUserLocation
initialRegion={
latest
? { latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
: { latitude: 30, longitude: 115, latitudeDelta: 10, longitudeDelta: 10 }
}
>
{/* GPS track polyline */}
{points.length > 1 && (
<Polyline coordinates={points} strokeColor="#4a9eff" strokeWidth={2} />
)}
{/* Measurement point markers */}
{points.map((p) => (
<Marker
key={p.frameId}
coordinate={{ latitude: p.latitude, longitude: p.longitude }}
anchor={{ x: 0.5, y: 0.5 }}
title={`帧 #${p.frameId}`}
>
<View style={styles.markerDot} />
</Marker>
))}
</MapView>
{/* Overlay info */}
<View style={styles.overlay}>
<Text style={styles.overlayText}>: {points.length}</Text>
{latest && (
<Text style={styles.overlayText}>
{formatCoord(latest.latitude, false)} {formatCoord(latest.longitude, true)}
</Text>
)}
</View>
{/* Center button */}
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
<Text style={styles.centerBtnText}></Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
markerDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#4a9eff',
borderWidth: 1,
borderColor: '#fff',
},
overlay: {
position: 'absolute',
top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
padding: 8,
gap: 2,
},
overlayText: { color: '#ddd', fontSize: 11 },
centerBtn: {
position: 'absolute',
bottom: 24,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(10,10,10,0.85)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#444',
},
centerBtnText: { color: '#4a9eff', fontSize: 22 },
});

159
app/(tabs)/records.tsx Normal file
View File

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { useDataStore } from '../../src/stores/dataStore';
import { exportCsv, shareFile } from '../../src/utils/export';
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
import type { MeasurementFrame } from '../../src/protocol/types';
export default function RecordsScreen() {
const { history, sessionId, clearHistory } = useDataStore();
const [exporting, setExporting] = useState(false);
const handleExportAll = async () => {
if (history.length === 0) {
Alert.alert('无数据', '当前会话没有采集记录');
return;
}
setExporting(true);
try {
const path = await exportCsv(history, sessionId);
await shareFile(path);
} catch (e: any) {
Alert.alert('导出失败', e.message);
} finally {
setExporting(false);
}
};
const handleClear = () => {
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
{ text: '取消', style: 'cancel' },
{ text: '清空', style: 'destructive', onPress: clearHistory },
]);
};
const renderItem = ({ item }: { item: MeasurementFrame }) => {
const m = item.meta;
const peakUV = item.adcUV[0]
? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
: 0;
return (
<View style={styles.item}>
<View style={styles.itemHeader}>
<Text style={styles.frameId}>#{item.frameId}</Text>
<Text style={styles.time}>{m ? formatUtc(m.utc) : '--'}</Text>
</View>
{m && (
<>
<Text style={styles.coord}>
{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 ? '✓' : '✗'}
</Text>
</View>
</View>
</>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* Toolbar */}
<View style={styles.toolbar}>
<Text style={styles.sessionText}>: {sessionId}</Text>
<Text style={styles.countText}>{history.length} </Text>
<View style={styles.toolbarBtns}>
<TouchableOpacity
style={[styles.toolBtn, styles.exportBtn, exporting && styles.btnDisabled]}
onPress={handleExportAll}
disabled={exporting}
>
{exporting ? (
<ActivityIndicator color="#44cc44" size="small" />
) : (
<Text style={styles.exportText}> CSV</Text>
)}
</TouchableOpacity>
<TouchableOpacity style={[styles.toolBtn, styles.clearBtn]} onPress={handleClear}>
<Text style={styles.clearText}></Text>
</TouchableOpacity>
</View>
</View>
{history.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptyHint}></Text>
</View>
) : (
<FlatList
data={history}
keyExtractor={(item) => String(item.frameId)}
renderItem={renderItem}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.sep} />}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
backgroundColor: '#111',
borderBottomWidth: 1,
borderBottomColor: '#2a2a2a',
gap: 8,
flexWrap: 'wrap',
},
sessionText: { color: '#555', fontSize: 10, flex: 1 },
countText: { color: '#888', fontSize: 12, fontWeight: '600' },
toolbarBtns: { flexDirection: 'row', gap: 8 },
toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
clearText: { color: '#ff6666', fontSize: 12 },
btnDisabled: { opacity: 0.4 },
list: { padding: 10, gap: 8 },
item: {
backgroundColor: '#181818',
borderRadius: 8,
padding: 10,
borderWidth: 1,
borderColor: '#2a2a2a',
},
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
coord: { color: '#888', fontSize: 10, marginBottom: 4 },
itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
meta: { color: '#555', fontSize: 10 },
gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
emptyText: { color: '#444', fontSize: 16 },
emptyHint: { color: '#333', fontSize: 12 },
});

114
app/(tabs)/waveform.tsx Normal file
View File

@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ChannelSelector } from '../../src/components/ChannelSelector';
import { useWaveform } from '../../src/hooks/useWaveform';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
export default function WaveformScreen() {
const { width } = useWindowDimensions();
const channelNum = useDeviceStore((s) => s.config.channelNum);
const [visible, setVisible] = useState(() => Array(6).fill(true));
const frame = useDataStore((s) => s.currentFrame);
const data = useWaveform(visible);
const toggleChannel = (idx: number) => {
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
};
const CHART_HEIGHT = 280;
return (
<View style={styles.container}>
<DeviceStatusBar />
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
{/* Waveform chart */}
<View style={styles.chartWrap}>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={width}
height={CHART_HEIGHT}
/>
) : (
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
<Text style={styles.placeholderText}>...</Text>
</View>
)}
</View>
{/* Channel peak values */}
{frame && (
<ScrollView style={styles.peakScroll}>
<View style={styles.peakRow}>
{frame.adcUV.map((ch, idx) => {
if (!visible[idx] || idx >= channelNum) return null;
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
const peak = ch[0] < 0 ? -absMax : absMax;
return (
<View key={idx} style={styles.peakCell}>
<Text style={styles.peakLabel}>CH{idx + 1}</Text>
<Text style={styles.peakValue}>{formatUV(absMax)}</Text>
</View>
);
})}
</View>
{/* Frame metadata */}
{frame.meta && (
<View style={styles.metaBlock}>
<Text style={styles.metaText}>
#{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
</Text>
<Text style={styles.metaText}>
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} {frame.meta.altitude.toFixed(1)} m
</Text>
<Text style={styles.metaText}>
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
</Text>
</View>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
placeholder: {
backgroundColor: '#1a1a1a',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: { color: '#444', fontSize: 14 },
peakScroll: { flex: 1 },
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
peakCell: {
backgroundColor: '#1a1a1a',
borderRadius: 8,
padding: 10,
minWidth: 90,
alignItems: 'center',
borderWidth: 1,
borderColor: '#2a2a2a',
},
peakLabel: { color: '#888', fontSize: 11 },
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
metaBlock: {
margin: 12,
backgroundColor: '#111',
borderRadius: 8,
padding: 10,
gap: 3,
},
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
});

View File

@ -1,56 +1,32 @@
import { useFonts } from 'expo-font';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export { ErrorBoundary } from 'expo-router';
export const unstable_settings = { initialRouteName: 'connect' };
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
SplashScreen.hideAsync();
}, []);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<GestureHandlerRootView style={styles.root}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#0d0d0d' } }}>
<Stack.Screen name="connect" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });

207
app/connect.tsx Normal file
View File

@ -0,0 +1,207 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Linking,
ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { useConnectionStore } from '../src/stores/connectionStore';
import { useDevice } from '../src/hooks/useDevice';
const LOG_MAX = 60;
export default function ConnectScreen() {
const { host, port, status, setHost, setPort } = useConnectionStore();
const { connect, disconnect } = useDevice();
const [logs, setLogs] = useState<string[]>(['准备连接...']);
const [portStr, setPortStr] = useState(String(port));
const addLog = (msg: string) => {
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
};
const handleConnect = () => {
const p = parseInt(portStr, 10);
if (!host || isNaN(p)) {
addLog('请检查 IP 和端口');
return;
}
setPort(p);
addLog(`正在连接 ${host}:${p}...`);
// Watch for status changes via the hook (side effect in useDevice)
useConnectionStore.subscribe((s) => {
if (s.status === 'connected') {
addLog('连接成功!');
router.replace('/(tabs)/control');
} else if (s.status === 'error') {
addLog(`错误: ${s.lastError}`);
} else if (s.status === 'reconnecting') {
addLog('断线,正在重连...');
}
});
connect();
};
const handleDisconnect = () => {
disconnect();
addLog('已断开连接');
};
const isConnecting = status === 'connecting' || status === 'reconnecting';
const isConnected = status === 'connected';
return (
<View style={styles.container}>
<Text style={styles.title}>TEM Receiver</Text>
<Text style={styles.subtitle}></Text>
{/* Connection steps */}
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.step}> WiFi </Text>
<TouchableOpacity style={styles.wifiBtn} onPress={() => Linking.openSettings()}>
<Text style={styles.wifiBtnText}> WiFi </Text>
</TouchableOpacity>
<Text style={styles.step}> </Text>
</View>
{/* IP / Port input */}
<View style={styles.card}>
<View style={styles.inputRow}>
<Text style={styles.inputLabel}> IP</Text>
<TextInput
style={styles.input}
value={host}
onChangeText={setHost}
keyboardType="numeric"
placeholder="192.168.4.1"
placeholderTextColor="#555"
autoCapitalize="none"
/>
</View>
<View style={styles.inputRow}>
<Text style={styles.inputLabel}></Text>
<TextInput
style={styles.input}
value={portStr}
onChangeText={setPortStr}
keyboardType="numeric"
placeholder="4321"
placeholderTextColor="#555"
/>
</View>
</View>
{/* Connect / Disconnect button */}
{!isConnected ? (
<TouchableOpacity
style={[styles.connectBtn, isConnecting && styles.connectBtnBusy]}
onPress={handleConnect}
disabled={isConnecting}
>
{isConnecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.connectBtnText}> </Text>
)}
</TouchableOpacity>
) : (
<View style={styles.connectedRow}>
<View style={styles.connectedBadge}>
<View style={styles.greenDot} />
<Text style={styles.connectedText}></Text>
</View>
<TouchableOpacity style={styles.goBtn} onPress={() => router.replace('/(tabs)/control')}>
<Text style={styles.goBtnText}> </Text>
</TouchableOpacity>
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
<Text style={styles.disconnectBtnText}></Text>
</TouchableOpacity>
</View>
)}
{/* Connection log */}
<View style={styles.logCard}>
<Text style={styles.logTitle}></Text>
<ScrollView style={styles.logScroll} nestedScrollEnabled>
{logs.map((l, i) => (
<Text key={i} style={styles.logLine}>{l}</Text>
))}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
card: {
backgroundColor: '#1a1a1a',
borderRadius: 10,
padding: 14,
marginBottom: 12,
borderWidth: 1,
borderColor: '#2a2a2a',
},
cardTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600', marginBottom: 8 },
step: { color: '#bbb', fontSize: 13, marginBottom: 6 },
wifiBtn: {
backgroundColor: '#0d2b55',
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 12,
alignSelf: 'flex-start',
marginBottom: 10,
},
wifiBtnText: { color: '#4a9eff', fontSize: 12 },
inputRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
inputLabel: { color: '#888', fontSize: 13, width: 50 },
input: {
flex: 1,
backgroundColor: '#111',
color: '#eee',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 6,
fontSize: 14,
borderWidth: 1,
borderColor: '#333',
},
connectBtn: {
backgroundColor: '#4a9eff',
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 12,
},
connectBtnBusy: { backgroundColor: '#2a5a99' },
connectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: 2 },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
connectedBadge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
greenDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#44cc44' },
connectedText: { color: '#44cc44', fontWeight: '600' },
goBtn: { flex: 1, backgroundColor: '#1a4a22', borderRadius: 8, padding: 10, alignItems: 'center' },
goBtnText: { color: '#44cc44', fontWeight: '600' },
disconnectBtn: { backgroundColor: '#3a1010', borderRadius: 8, padding: 10 },
disconnectBtnText: { color: '#ff6666' },
logCard: {
flex: 1,
backgroundColor: '#111',
borderRadius: 10,
padding: 10,
borderWidth: 1,
borderColor: '#222',
},
logTitle: { color: '#666', fontSize: 11, marginBottom: 4 },
logScroll: { flex: 1 },
logLine: { color: '#555', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 },
});

465
package-lock.json generated
View File

@ -8,23 +8,34 @@
"name": "trilooptem_app",
"version": "1.0.0",
"dependencies": {
"@shopify/react-native-skia": "2.6.2",
"expo": "~56.0.9",
"expo-constants": "~56.0.17",
"expo-dev-client": "~56.0.19",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
"expo-linking": "~56.0.13",
"expo-location": "~56.0.16",
"expo-router": "~56.2.9",
"expo-sharing": "~56.0.16",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "~56.0.4",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-web-browser": "~56.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-maps": "1.27.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-tcp-socket": "^6.4.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3"
"react-native-worklets": "0.8.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "~19.2.2",
@ -1128,6 +1139,40 @@
"node": ">=6.9.0"
}
},
"node_modules/@callstack/react-theme-provider": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz",
"integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^3.2.0",
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"react": ">=16.3.0"
}
},
"node_modules/@callstack/react-theme-provider/node_modules/deepmerge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@expo-google-fonts/material-symbols": {
"version": "0.4.38",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz",
@ -2531,6 +2576,38 @@
}
}
},
"node_modules/@shopify/react-native-skia": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.6.2.tgz",
"integrity": "sha512-NzZ3+MRedZAUhguWw9DTCpWFd09Bq+tdGWhimGfJLGckuyoWGyimTiNTmaO2DeeivHTnGdv+eXbw7j/AV3LkRQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"canvaskit-wasm": "0.41.0",
"react-native-skia-android": "147.1.0",
"react-native-skia-apple-ios": "147.1.0",
"react-native-skia-apple-macos": "147.1.0",
"react-native-skia-apple-tvos": "147.1.0",
"react-reconciler": "0.31.0"
},
"bin": {
"install-skia": "scripts/install-libs.js",
"setup-skia-web": "scripts/setup-canvaskit.js"
},
"peerDependencies": {
"react": ">=19.0",
"react-native": ">=0.78",
"react-native-reanimated": ">=3.19.1"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@ -2632,6 +2709,18 @@
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -2705,6 +2794,12 @@
"integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"license": "ISC"
},
"node_modules/@webgpu/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@ -2856,6 +2951,12 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
"license": "MIT"
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
@ -3133,6 +3234,30 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -3180,6 +3305,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvaskit-wasm": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz",
"integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==",
"license": "BSD-3-Clause",
"dependencies": {
"@webgpu/types": "0.1.21"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3675,6 +3809,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expo": {
"version": "56.0.9",
"resolved": "https://registry.npmjs.org/expo/-/expo-56.0.9.tgz",
@ -3767,6 +3907,59 @@
"react-native": "*"
}
},
"node_modules/expo-dev-client": {
"version": "56.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.19.tgz",
"integrity": "sha512-Mk2AsYGPBb+G30rwNHZvIE0Mi5Zd0yIZ2UdvIqllZjaWITiLbSqHklTgwY3KUs4/HrusXdZrfX7GqJGcUhOPiw==",
"license": "MIT",
"dependencies": {
"expo-dev-launcher": "~56.0.19",
"expo-dev-menu": "~56.0.16",
"expo-dev-menu-interface": "~56.0.0",
"expo-manifests": "~56.0.4",
"expo-updates-interface": "~56.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-launcher": {
"version": "56.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.19.tgz",
"integrity": "sha512-O1oJPNYLtVQT+ByIFVm3VsEdjeyvXVr5qCV4DXKGDNg+rXNqRh4GrmLRkupHb1T3tdgLAJ7FRsZL9XY3GoAyPA==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^56.0.0",
"expo-dev-menu": "~56.0.16",
"expo-manifests": "~56.0.4"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-dev-menu": {
"version": "56.0.16",
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.16.tgz",
"integrity": "sha512-aVgoe+YGhrQnpwiB5BRI7G+uQnGHMUij32bBnEVdc6eJrVZCStxQlV9NeFbbXxrDhLJt6OSqbCHbLR+XToWUUA==",
"license": "MIT",
"dependencies": {
"expo-dev-menu-interface": "~56.0.0"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-dev-menu-interface": {
"version": "56.0.1",
"resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-56.0.1.tgz",
"integrity": "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "56.0.7",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz",
@ -3803,6 +3996,12 @@
"react-native": "*"
}
},
"node_modules/expo-json-utils": {
"version": "56.0.0",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz",
"integrity": "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg==",
"license": "MIT"
},
"node_modules/expo-keep-awake": {
"version": "56.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz",
@ -3828,6 +4027,30 @@
"react-native": "*"
}
},
"node_modules/expo-location": {
"version": "56.0.16",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.16.tgz",
"integrity": "sha512-L8Q8xyRd/r39rQU4/k6m2CUu7ALaE57XADL3PbP4XgRgZUH4JQSqb24SFd0iRUCuodnKawreO3G+JyskC50hgw==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.10.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-manifests": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-56.0.4.tgz",
"integrity": "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw==",
"license": "MIT",
"dependencies": {
"expo-json-utils": "~56.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "56.0.15",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.15.tgz",
@ -3957,6 +4180,22 @@
"node": ">=20.16.0"
}
},
"node_modules/expo-sharing": {
"version": "56.0.16",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.16.tgz",
"integrity": "sha512-tp29hiWunLkjIToDqiiJQo4Nfo//92DAg/pcRWENLlnTXy/qg58uxy1O5K6xVZ7sVZIJqwEDLzDedw3PlDmWmg==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^56.0.8",
"@expo/config-types": "^56.0.5",
"@expo/plist": "^0.7.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-splash-screen": {
"version": "56.0.10",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-56.0.10.tgz",
@ -3971,6 +4210,20 @@
"expo": "*"
}
},
"node_modules/expo-sqlite": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-56.0.4.tgz",
"integrity": "sha512-Ak8TUyrvK7C/J4BHBfcb8BacFrH8I+b+zqeSTKg5B02Z13lxljvuqI8UvKbRNa5BKprlxrqabZickGwacRkM9g==",
"license": "MIT",
"dependencies": {
"await-lock": "^2.2.2"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-status-bar": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz",
@ -3998,6 +4251,15 @@
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "56.0.2",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-56.0.2.tgz",
"integrity": "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-web-browser": {
"version": "56.0.5",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-56.0.5.tgz",
@ -4485,6 +4747,21 @@
"hermes-estree": "0.33.3"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@ -4551,6 +4828,26 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -6439,13 +6736,15 @@
}
},
"node_modules/react-native-gesture-handler": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-3.0.0.tgz",
"integrity": "sha512-6E8o9D2sHwhFGiU0c4aCweMdJwIbQeBV+dq3IQ3HcqKhVGzg7ccEycap6i0zGCtIYfs3V29Xd4OycwcRj5qxBQ==",
"version": "2.31.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz",
"integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"@types/react-test-renderer": "^19.1.0",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
@ -6463,6 +6762,73 @@
"react-native": "*"
}
},
"node_modules/react-native-maps": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz",
"integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.13"
},
"engines": {
"node": ">= 20.19.4"
},
"peerDependencies": {
"react": ">= 18.3.1",
"react-native": ">= 0.76.0",
"react-native-web": ">= 0.11"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/react-native-paper": {
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.3.tgz",
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
"license": "MIT",
"workspaces": [
"example",
"docs"
],
"dependencies": {
"@callstack/react-theme-provider": "^3.0.9",
"color": "^3.1.2",
"use-latest-callback": "^0.2.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-safe-area-context": "*"
}
},
"node_modules/react-native-paper/node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"node_modules/react-native-paper/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/react-native-paper/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
"node_modules/react-native-reanimated": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
@ -6516,6 +6882,47 @@
"react-native": ">=0.82.0"
}
},
"node_modules/react-native-skia-android": {
"version": "147.1.0",
"resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-147.1.0.tgz",
"integrity": "sha512-pWA0M0G74AhjEop0HLCkjWJMup2HJxOmuUjfPt6kSDhYeWKVx8AEzWh0Fh19ah78zE/s4hD0Of0Tyem5shhiTg==",
"license": "MIT"
},
"node_modules/react-native-skia-apple-ios": {
"version": "147.1.0",
"resolved": "https://registry.npmjs.org/react-native-skia-apple-ios/-/react-native-skia-apple-ios-147.1.0.tgz",
"integrity": "sha512-cr4rWe4Bf0H0TTutUp5cgHt5/Felttl1bh4BAAAsgAeL2F10FAK9urX8spjUshzMwjqXD7rNOWuFzU6ZcNlGKw==",
"license": "MIT"
},
"node_modules/react-native-skia-apple-macos": {
"version": "147.1.0",
"resolved": "https://registry.npmjs.org/react-native-skia-apple-macos/-/react-native-skia-apple-macos-147.1.0.tgz",
"integrity": "sha512-Qbv0Y7LgawtRKuGk8gnGeh8nDWwNiu03LcX0mVaQzBBbxFDYvqejanA+AkO3p8gsQb+fsXRc9DAk+U8cBnzZvA==",
"license": "MIT"
},
"node_modules/react-native-skia-apple-tvos": {
"version": "147.1.0",
"resolved": "https://registry.npmjs.org/react-native-skia-apple-tvos/-/react-native-skia-apple-tvos-147.1.0.tgz",
"integrity": "sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==",
"license": "MIT"
},
"node_modules/react-native-tcp-socket": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/react-native-tcp-socket/-/react-native-tcp-socket-6.4.1.tgz",
"integrity": "sha512-2+zya9ielB8nWfMYVULB64ZqLzQD32qIzTnDef7NM1BChaJiA1btkJTBL+8WnyBrujjlCBVxBC3gAkXtG5hmvQ==",
"license": "MIT",
"dependencies": {
"buffer": "^5.4.3",
"eventemitter3": "^4.0.7"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Rapsssito"
},
"peerDependencies": {
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@ -6608,6 +7015,27 @@
"node": ">=10"
}
},
"node_modules/react-reconciler": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@ -7812,6 +8240,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -3,23 +3,34 @@
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@shopify/react-native-skia": "2.6.2",
"expo": "~56.0.9",
"expo-constants": "~56.0.17",
"expo-dev-client": "~56.0.19",
"expo-file-system": "~56.0.7",
"expo-font": "~56.0.5",
"expo-linking": "~56.0.13",
"expo-location": "~56.0.16",
"expo-router": "~56.2.9",
"expo-sharing": "~56.0.16",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "~56.0.4",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-web-browser": "~56.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-maps": "1.27.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-tcp-socket": "^6.4.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3"
"react-native-worklets": "0.8.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "~19.2.2",

View File

@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { CHANNEL_COLORS } from '../protocol/constants';
interface Props {
maxChannels: number;
visible: boolean[];
onToggle: (idx: number) => void;
}
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
return (
<View style={styles.row}>
{Array.from({ length: maxChannels }, (_, i) => (
<TouchableOpacity
key={i}
style={[styles.chip, { borderColor: CHANNEL_COLORS[i], opacity: visible[i] ? 1 : 0.35 }]}
onPress={() => onToggle(i)}
activeOpacity={0.7}
>
<View style={[styles.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={[styles.label, { color: visible[i] ? CHANNEL_COLORS[i] : '#888' }]}>
CH{i + 1}
</Text>
</TouchableOpacity>
))}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 3,
gap: 4,
},
dot: { width: 6, height: 6, borderRadius: 3 },
label: { fontSize: 11, fontWeight: '600' },
});

View File

@ -0,0 +1,73 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
import { formatBattery, formatTemperature, formatCoord } from '../utils/format';
function Indicator({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<View style={styles.indicator}>
<View style={[styles.dot, { backgroundColor: ok === false ? '#ff4444' : ok ? '#44cc44' : '#888888' }]} />
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{value}</Text>
</View>
);
}
export function DeviceStatusBar() {
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
const frame = useDataStore((s) => s.currentFrame);
const meta = frame?.meta;
const lat = meta?.latitude ?? 0;
const lon = meta?.longitude ?? 0;
const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
const running = deviceStatus === 'running';
const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
return (
<View style={styles.bar}>
<View style={[styles.statusBadge, { borderColor: statusColor }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
<Indicator label="GPS" value={gpsStr} ok={hasGps ? true : false} />
<Indicator label="SD" value={sdStatus > 0 ? 'OK' : '--'} ok={sdStatus > 0} />
<Indicator label="电池" value={batteryVolt ? formatBattery(batteryVolt) : '--'} />
<Indicator label="温度" value={temperature ? formatTemperature(temperature) : '--'} />
</View>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
backgroundColor: '#111',
paddingHorizontal: 12,
paddingVertical: 6,
alignItems: 'center',
flexWrap: 'wrap',
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 6,
paddingVertical: 2,
gap: 4,
},
statusDot: { width: 6, height: 6, borderRadius: 3 },
statusText: { fontSize: 11, fontWeight: '600' },
indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
dot: { width: 6, height: 6, borderRadius: 3 },
label: { color: '#888', fontSize: 10 },
value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
});

View File

@ -0,0 +1,235 @@
import React from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import {
SEND_FREQ_TABLE,
SAMPLE_FREQ_TABLE,
AMP_RATIO_TABLE,
SOURCE_MODE_TABLE,
} from '../protocol/constants';
interface PickerRowProps {
label: string;
options: readonly { code: number; label: string }[];
value: number;
onChange: (code: number) => void;
}
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.optScroll}>
{options.map((o) => (
<TouchableOpacity
key={o.code}
style={[styles.optChip, o.code === value && styles.optChipActive]}
onPress={() => onChange(o.code)}
>
<Text style={[styles.optText, o.code === value && styles.optTextActive]}>
{o.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
interface NumberRowProps {
label: string;
value: number;
onChangeText: (v: string) => void;
suffix?: string;
keyboardType?: 'numeric';
}
function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={String(value)}
onChangeText={onChangeText}
keyboardType={keyboardType}
selectTextOnFocus
placeholderTextColor="#555"
/>
{suffix && <Text style={styles.suffix}>{suffix}</Text>}
</View>
</View>
);
}
interface FormProps {
onAnyChange?: () => void;
}
export function ParamForm({ onAnyChange }: FormProps = {}) {
const { config, updateConfig } = useDeviceStore();
const patch = (partial: Partial<typeof config>) => {
updateConfig(partial);
onAnyChange?.();
};
return (
<View style={styles.container}>
{/* Channel selector */}
<View style={styles.row}>
<Text style={styles.rowLabel}></Text>
<View style={styles.channelRow}>
{[1, 2, 3, 4, 5, 6].map((n) => (
<TouchableOpacity
key={n}
style={[styles.chChip, config.channelNum === n && styles.chChipActive]}
onPress={() => patch({ channelNum: n })}
>
<Text style={[styles.chText, config.channelNum === n && styles.chTextActive]}>
{n}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<PickerRow
label="发射频率"
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
label="采样点数"
value={config.sampleDepth}
onChangeText={(v) => patch({ sampleDepth: parseInt(v, 10) || 0 })}
/>
<NumberRow
label="叠加次数"
value={config.accNum}
onChangeText={(v) => patch({ accNum: parseInt(v, 10) || 0 })}
/>
<PickerRow
label="增益"
options={AMP_RATIO_TABLE}
value={config.ampRatio}
onChange={(v) => patch({ ampRatio: v })}
/>
<PickerRow
label="源模式"
options={SOURCE_MODE_TABLE}
value={config.sourceMode}
onChange={(v) => patch({ sourceMode: v })}
/>
{/* Reverse accumulation flags */}
<View style={styles.row}>
<Text style={styles.rowLabel}></Text>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>CH1-3</Text>
<Switch
value={config.negAcc123}
onValueChange={(v) => patch({ negAcc123: v })}
thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
trackColor={{ false: '#333', true: '#1a4a88' }}
/>
<Text style={styles.switchLabel}>CH4-6</Text>
<Switch
value={config.negAcc456}
onValueChange={(v) => patch({ negAcc456: v })}
thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
trackColor={{ false: '#333', true: '#1a4a88' }}
/>
</View>
</View>
<NumberRow
label="补偿延时"
value={config.compDisableDelay}
onChangeText={(v) => patch({ compDisableDelay: parseInt(v, 10) || 0 })}
suffix="×50μs"
/>
{/* File prefix */}
<View style={styles.row}>
<Text style={styles.rowLabel}></Text>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={config.filePrefix}
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
maxLength={15}
autoCapitalize="none"
placeholderTextColor="#555"
/>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { backgroundColor: '#111' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#2a2a2a',
gap: 10,
},
rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
optScroll: { flex: 1 },
optChip: {
borderWidth: 1,
borderColor: '#444',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 3,
marginRight: 6,
},
optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
optText: { color: '#888', fontSize: 11 },
optTextActive: { color: '#4a9eff', fontWeight: '600' },
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
input: {
flex: 1,
backgroundColor: '#1e1e1e',
color: '#eee',
fontSize: 13,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 4,
borderWidth: 1,
borderColor: '#333',
},
suffix: { color: '#666', fontSize: 11, marginLeft: 6 },
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
chChip: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#444',
alignItems: 'center',
justifyContent: 'center',
},
chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
chText: { color: '#888', fontSize: 12, fontWeight: '600' },
chTextActive: { color: '#4a9eff' },
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
switchLabel: { color: '#888', fontSize: 12 },
});

View File

@ -0,0 +1,153 @@
import React, { useMemo } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
import { CHANNEL_COLORS } from '../protocol/constants';
import type { WaveformData } from '../hooks/useWaveform';
interface Props {
data: WaveformData;
visibleChannels: boolean[];
width: number;
height: number;
}
const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
export function WaveformChart({ data, visibleChannels, width, height }: Props) {
const plotW = width - PADDING.left - PADDING.right;
const plotH = height - PADDING.top - PADDING.bottom;
const yMin = data.minLogUV - 0.5;
const yMax = data.maxLogUV + 0.5;
const yRange = yMax - yMin;
// Map log10(absVal) → canvas y (top=high, bottom=low)
const toY = (uv: number): number => {
const absV = Math.abs(uv);
if (absV < 1e-12) return PADDING.top + plotH;
const logV = Math.log10(absV);
return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
};
// Map sample index → canvas x
const toX = (i: number, total: number): number =>
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
// Y-axis grid lines and labels
const yGridLines = useMemo(() => {
return Y_TICKS.filter((v) => {
const log = Math.log10(v);
return log >= yMin && log <= yMax;
});
}, [yMin, yMax]);
// Channel paths
const channelPaths = useMemo(() => {
return data.channels.map((ch, idx) => {
if (!visibleChannels[idx]) return null;
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < ch.length; i++) {
const x = toX(i, ch.length);
const y = toY(ch[i]);
if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
if (!moved) {
path.moveTo(x, y);
moved = true;
} else {
path.lineTo(x, y);
}
}
return path;
});
}, [data, visibleChannels, plotW, plotH, yMin, yRange]);
return (
<View style={[styles.container, { width, height }]}>
<Canvas style={{ width, height }}>
{/* Background */}
{/* Y-axis grid lines */}
{yGridLines.map((v) => {
const y = toY(v);
return (
<Line
key={v}
p1={vec(PADDING.left, y)}
p2={vec(PADDING.left + plotW, y)}
color="#333333"
strokeWidth={0.5}
/>
);
})}
{/* X-axis baseline */}
<Line
p1={vec(PADDING.left, PADDING.top + plotH)}
p2={vec(PADDING.left + plotW, PADDING.top + plotH)}
color="#666666"
strokeWidth={1}
/>
{/* Channel waveform paths */}
{channelPaths.map((path, idx) => {
if (!path || !visibleChannels[idx]) return null;
return (
<Path
key={idx}
path={path}
color={CHANNEL_COLORS[idx]}
style="stroke"
strokeWidth={1.5}
strokeJoin="round"
strokeCap="round"
/>
);
})}
</Canvas>
{/* Y-axis labels (React Native text overlay, positioned absolutely) */}
{yGridLines.map((v) => {
const y = toY(v);
const label = v >= 1000 ? `${v / 1000}k` : v >= 1 ? `${v}` : `${v}`;
return (
<Text
key={v}
style={[styles.axisLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}
>
{label}
</Text>
);
})}
{/* X-axis label */}
<Text style={[styles.xLabel, { top: height - 18, left: PADDING.left }]}>
</Text>
{/* Y-axis unit */}
<Text style={[styles.yUnit, { top: 0, left: 0 }]}>μV</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { backgroundColor: '#1a1a1a', position: 'relative' },
axisLabel: {
position: 'absolute',
color: '#aaaaaa',
fontSize: 9,
textAlign: 'right',
},
xLabel: {
position: 'absolute',
color: '#aaaaaa',
fontSize: 9,
},
yUnit: {
position: 'absolute',
color: '#888888',
fontSize: 9,
},
});

100
src/hooks/useDevice.ts Normal file
View File

@ -0,0 +1,100 @@
import { useCallback, useState } from 'react';
import { Alert } from 'react-native';
import { tcpService } from '../services/TcpService';
import { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop } from '../services/DeviceService';
import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import type { SetupConfig } from '../protocol/types';
export function useDevice() {
const [busy, setBusy] = useState(false);
const connStore = useConnectionStore();
const devStore = useDeviceStore();
const connect = useCallback(() => {
const { host, port, setStatus, setError } = useConnectionStore.getState();
setStatus('connecting');
tcpService.connect(host, port, {
onConnect: () => setStatus('connected'),
onFrame: handleIncomingFrame,
onError: (err) => setError(err.message),
onClose: () => {
if (useConnectionStore.getState().status === 'connected') {
setStatus('reconnecting');
}
},
});
}, []);
const disconnect = useCallback(() => {
tcpService.disconnect();
useConnectionStore.getState().setStatus('disconnected');
useDeviceStore.getState().setDeviceStatus('idle');
}, []);
const setup = useCallback(async (cfg?: SetupConfig) => {
const config = cfg ?? useDeviceStore.getState().config;
setBusy(true);
try {
const ack = await deviceSetup(config);
if (ack.result !== 0x01) {
Alert.alert('配置失败', ack.reason || '设备返回失败');
}
return ack;
} catch (e: any) {
Alert.alert('配置超时', e.message);
} finally {
setBusy(false);
}
}, []);
const startContinuous = useCallback(async () => {
setBusy(true);
try {
const ack = await deviceStartContinuous();
if (ack.result !== 0x01) Alert.alert('启动失败', ack.reason);
return ack;
} catch (e: any) {
Alert.alert('启动超时', e.message);
} finally {
setBusy(false);
}
}, []);
const startSingle = useCallback(async () => {
setBusy(true);
try {
const ack = await deviceStartSingle();
if (ack.result !== 0x01) Alert.alert('单次采集失败', ack.reason);
return ack;
} catch (e: any) {
Alert.alert('单次采集超时', e.message);
} finally {
setBusy(false);
}
}, []);
const stop = useCallback(async () => {
setBusy(true);
try {
await deviceStop();
} catch (e: any) {
Alert.alert('停止超时', e.message);
} finally {
setBusy(false);
}
}, []);
return {
busy,
connected: connStore.status === 'connected',
status: connStore.status,
deviceStatus: devStore.deviceStatus,
connect,
disconnect,
setup,
startContinuous,
startSingle,
stop,
};
}

82
src/hooks/useWaveform.ts Normal file
View File

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { useDataStore } from '../stores/dataStore';
const MAX_DISPLAY_POINTS = 512; // downsample to this many points for rendering
// Downsample an array using peak-hold (envelope) method
function downsample(data: Float64Array, targetLen: number): Float64Array {
if (data.length <= targetLen) return data;
const ratio = data.length / targetLen;
const out = new Float64Array(targetLen);
for (let i = 0; i < targetLen; i++) {
const start = Math.floor(i * ratio);
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
let maxAbs = 0;
let maxVal = 0;
for (let j = start; j < end; j++) {
if (Math.abs(data[j]) > maxAbs) {
maxAbs = Math.abs(data[j]);
maxVal = data[j];
}
}
out[i] = maxVal;
}
return out;
}
export interface WaveformData {
channels: Float64Array[]; // downsampled μV data per channel
timeMs: Float64Array; // time axis in ms
minLogUV: number; // log10 of min absolute non-zero value
maxLogUV: number; // log10 of max absolute value
sampleDepth: number; // original samples per channel
}
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
const frame = useDataStore((s) => s.currentFrame);
return useMemo(() => {
if (!frame) return null;
const { adcUV, meta } = frame;
const sampleDepth = adcUV[0]?.length ?? 0;
if (sampleDepth === 0) return null;
// Build time axis in ms using sample freq (approximation)
// We don't have actual sample rate here, so we use index/total
// The real time axis depends on the sendFreq and sampleFreq config
// Use normalised 01 for now, App can scale by actual period
const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
const timeMs = new Float64Array(displayLen);
for (let i = 0; i < displayLen; i++) {
timeMs[i] = i / displayLen; // normalised 01 (multiply by period in ms externally)
}
let globalMin = Infinity;
let globalMax = -Infinity;
const channels: Float64Array[] = adcUV.map((ch, idx) => {
if (!visibleChannels[idx]) return new Float64Array(displayLen);
const ds = downsample(ch, displayLen);
for (let i = 0; i < ds.length; i++) {
const absV = Math.abs(ds[i]);
if (absV > 1e-9) {
globalMin = Math.min(globalMin, absV);
globalMax = Math.max(globalMax, absV);
}
}
return ds;
});
if (!isFinite(globalMin)) globalMin = 1e-3;
if (!isFinite(globalMax)) globalMax = 1e6;
return {
channels,
timeMs,
minLogUV: Math.log10(globalMin),
maxLogUV: Math.log10(globalMax),
sampleDepth,
};
}, [frame, visibleChannels]);
}

94
src/protocol/constants.ts Normal file
View File

@ -0,0 +1,94 @@
export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
export const FRAME_FLAG_4BYTE = 0xfe;
export const DEFAULT_DEVICE_IP = '192.168.4.1';
export const DEFAULT_TCP_PORT = 4321;
export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit
export const FuncCode = {
// App → Device
SETUP_REQ: 0x01,
CONTINUOUS_REQ: 0x02,
SINGLE_REQ: 0x03,
STOP_REQ: 0x04,
ACTIVE_REQ: 0x05,
SPLITFRAME_REQ: 0x08,
// Device → App
SETUP_ACK: 0x81,
CONTINUOUS_ACK: 0x82,
SINGLE_ACK: 0x83,
STOP_ACK: 0x84,
DATA_ACK: 0x85,
SPLITFRAME_ACK: 0x88,
COMP_DATA_ACK: 0x95,
} as const;
export const AckResult = { OK: 0x01, FAIL: 0x02 } as const;
export const DataChannel = { USB: 0x01, P900: 0x02, WIFI: 0x03 } as const;
export const SplitFrameCmd = { START: 0x01, REQUEST: 0x02, STOP: 0x03, LAST: 0x82 } as const;
export const SEND_FREQ_TABLE = [
{ code: 0x00, label: '0.5 Hz' },
{ code: 0x01, label: '1 Hz' },
{ code: 0x02, label: '2 Hz' },
{ code: 0x03, label: '4 Hz' },
{ code: 0x04, label: '8 Hz' },
{ code: 0x05, label: '12.5 Hz' },
{ code: 0x06, label: '16 Hz' },
{ code: 0x07, label: '25 Hz' },
{ code: 0x08, label: '32 Hz' },
{ code: 0x09, label: '50 Hz' },
{ code: 0x0a, label: '64 Hz' },
// 0xFE (ZTEM) not supported
] as const;
export const SAMPLE_FREQ_TABLE = [
{ code: 0x00, label: '250 kHz' },
{ code: 0x01, label: '125 kHz' },
{ code: 0x02, label: '62.5 kHz' },
{ code: 0x03, label: '31.25 kHz' },
{ code: 0x04, label: '15.6 kHz' },
{ code: 0x05, label: '7.8 kHz' },
{ code: 0x06, label: '3.9 kHz' },
{ code: 0x07, label: '1.95 kHz' },
{ code: 0x08, label: '977 Hz' },
{ code: 0x09, label: '488 Hz' },
{ code: 0x0a, label: '244 Hz' },
{ code: 0x0b, label: '122 Hz' },
{ code: 0x0c, label: '61 Hz' },
] as const;
// Gain table: ampRatio code → multiplier
export const AMP_GAIN = [0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 1];
export const AMP_RATIO_TABLE = [
{ code: 0x00, label: '1/8×', gain: 0.125 },
{ code: 0x01, label: '1/4×', gain: 0.25 },
{ code: 0x02, label: '1/2×', gain: 0.5 },
{ code: 0x03, label: '1×', gain: 1 },
{ code: 0x04, label: '2×', gain: 2 },
{ code: 0x05, label: '4×', gain: 4 },
{ code: 0x06, label: '8×', gain: 8 },
{ code: 0x07, label: '16×', gain: 16 },
{ code: 0x08, label: '32×', gain: 32 },
{ code: 0x09, label: '64×', gain: 64 },
{ code: 0x0a, label: '128×', gain: 128 },
{ code: 0x0b, label: '自动', gain: 1 },
] as const;
export const SOURCE_MODE_TABLE = [
{ code: 0x00, label: '单源 A', sourceNum: 1 },
{ code: 0x03, label: '双源 A-B', sourceNum: 2 },
{ code: 0x04, label: '双源 A-C', sourceNum: 2 },
{ code: 0x06, label: '三源 A-B-C', sourceNum: 3 },
{ code: 0x07, label: '磁源 A', sourceNum: 1 },
] as const;
export const CHANNEL_COLORS = [
'#FF4444', // CH1 红
'#44BB44', // CH2 绿
'#4444FF', // CH3 蓝
'#FFAA00', // CH4 橙
'#AA44FF', // CH5 紫
'#00CCCC', // CH6 青
] as const;

97
src/protocol/packet.ts Normal file
View File

@ -0,0 +1,97 @@
import { FRAME_FLAG_4BYTE, FuncCode, DataChannel } from './constants';
import type { SetupConfig } from './types';
const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
// Build the 10-byte packet header
function buildHeader(func: number, payloadLen: number): Uint8Array {
const buf = new Uint8Array(10);
buf.set(HEADER_MAGIC, 0);
buf[4] = FRAME_FLAG_4BYTE;
buf[5] = func;
buf[6] = payloadLen & 0xff;
buf[7] = (payloadLen >> 8) & 0xff;
buf[8] = (payloadLen >> 16) & 0xff;
buf[9] = (payloadLen >> 24) & 0xff;
return buf;
}
// Build a command packet with a zero-filled 54-byte payload (minimum)
function buildSimpleCommand(func: number): Uint8Array {
const payload = new Uint8Array(54);
const header = buildHeader(func, 54);
const pkt = new Uint8Array(10 + 54);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}
// Write a little-endian uint16 into buf at offset
function writeUint16LE(buf: Uint8Array, offset: number, value: number) {
buf[offset] = value & 0xff;
buf[offset + 1] = (value >> 8) & 0xff;
}
// Write a little-endian uint32 into buf at offset
function writeUint32LE(buf: Uint8Array, offset: number, value: number) {
buf[offset] = value & 0xff;
buf[offset + 1] = (value >> 8) & 0xff;
buf[offset + 2] = (value >> 16) & 0xff;
buf[offset + 3] = (value >> 24) & 0xff;
}
// Write ASCII string (null-padded) into buf starting at offset
function writeString(buf: Uint8Array, offset: number, str: string, maxLen: number) {
for (let i = 0; i < maxLen; i++) {
buf[offset + i] = i < str.length ? str.charCodeAt(i) & 0xff : 0;
}
}
export function buildSetupPacket(cfg: SetupConfig): Uint8Array {
const PAYLOAD_LEN = 64; // sizeof(setupReq_t)
const payload = new Uint8Array(PAYLOAD_LEN);
payload[0] = cfg.channelNum & 0xff;
payload[1] = cfg.sendFreq & 0xff;
payload[2] = cfg.sampleFreq & 0xff;
writeUint16LE(payload, 3, cfg.sampleDepth);
// accNum is 14-bit; bit1 = negAcc456; bit0 = negAcc123
let accFlags = (cfg.accNum & 0x3fff) << 2;
if (cfg.negAcc456) accFlags |= 0x02;
if (cfg.negAcc123) accFlags |= 0x01;
writeUint16LE(payload, 5, accFlags);
payload[7] = cfg.ampRatio & 0xff;
payload[8] = cfg.dataChannel & 0xff;
payload[9] = cfg.compRes & 0xff;
payload[10] = cfg.secondSampleFreq & 0xff;
writeUint16LE(payload, 11, cfg.compDisableDelay);
payload[13] = cfg.sourceMode & 0xff;
writeUint16LE(payload, 14, cfg.batteryVoltageMin);
// bytes 16-37: reserved (zeros)
writeString(payload, 38, cfg.filePrefix, 16);
const header = buildHeader(FuncCode.SETUP_REQ, PAYLOAD_LEN);
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}
export const buildContinuousReq = () => buildSimpleCommand(FuncCode.CONTINUOUS_REQ);
export const buildSingleReq = () => buildSimpleCommand(FuncCode.SINGLE_REQ);
export const buildStopReq = () => buildSimpleCommand(FuncCode.STOP_REQ);
export const buildActiveReq = () => buildSimpleCommand(FuncCode.ACTIVE_REQ);
export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array {
const PAYLOAD_LEN = 54;
const payload = new Uint8Array(PAYLOAD_LEN);
payload[0] = subCmd & 0xff;
writeUint16LE(payload, 1, frameNo);
const header = buildHeader(FuncCode.SPLITFRAME_REQ, PAYLOAD_LEN);
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}

178
src/protocol/parser.ts Normal file
View File

@ -0,0 +1,178 @@
import { MAX_PAYLOAD_SIZE } from './constants';
import type { RawFrame, MeasurementMeta, MeasurementFrame, AckPacket } from './types';
import { FuncCode, AMP_GAIN } from './constants';
// Streaming TCP frame parser.
// Handles packet fragmentation (TCP splits large packets across multiple reads).
export class TemFrameParser {
private buf: Uint8Array;
private len = 0;
private readonly capacity: number;
constructor(capacity = 2 * 1024 * 1024) {
this.capacity = capacity;
this.buf = new Uint8Array(capacity);
}
feed(chunk: Uint8Array | ArrayBuffer | { buffer: ArrayBuffer; byteOffset: number; byteLength: number }): RawFrame[] {
let incoming: Uint8Array;
if (chunk instanceof Uint8Array) {
incoming = chunk;
} else if (chunk instanceof ArrayBuffer) {
incoming = new Uint8Array(chunk);
} else {
incoming = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
}
if (this.len + incoming.length > this.capacity) {
// Buffer overflow — reset and resync
this.len = 0;
}
this.buf.set(incoming, this.len);
this.len += incoming.length;
const results: RawFrame[] = [];
let offset = 0;
while (offset < this.len) {
const magic = this.findMagic(offset);
if (magic === -1) {
// No header found — keep last 3 bytes in case they start a header
const keep = Math.min(3, this.len - offset);
this.buf.copyWithin(0, this.len - keep);
this.len = keep;
return results;
}
offset = magic;
// Wait until we have the full 10-byte header
if (this.len - offset < 10) break;
const flag = this.buf[offset + 4];
const func = this.buf[offset + 5];
const payloadLen = this.readUint32LE(offset + 6);
if (payloadLen > MAX_PAYLOAD_SIZE) {
// Bogus length — skip this magic and search again
offset += 4;
continue;
}
const totalLen = 10 + payloadLen;
if (this.len - offset < totalLen) break; // wait for more data
const payload = new Uint8Array(totalLen - 10);
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
results.push({ flag, func, payload });
offset += totalLen;
}
// Compact — move remaining bytes to front
if (offset > 0 && offset <= this.len) {
this.buf.copyWithin(0, offset, this.len);
this.len -= offset;
}
return results;
}
reset() {
this.len = 0;
}
private findMagic(start: number): number {
const b = this.buf;
const end = this.len - 3;
for (let i = start; i <= end; i++) {
if (b[i] === 0x68 && b[i + 1] === 0x68 && b[i + 2] === 0xff && b[i + 3] === 0xff) {
return i;
}
}
return -1;
}
private readUint32LE(offset: number): number {
const b = this.buf;
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
}
}
// Parse the metadata header of a 0x85 or 0x95 DATA_ACK payload
export function parseMetadata(payload: Uint8Array): MeasurementMeta | null {
if (payload.length < 54) return null;
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
let o = 0;
const devId = view.getUint8(o++);
const utc = view.getUint32(o, true); o += 4;
const longitude = view.getFloat64(o, true); o += 8;
const latitude = view.getFloat64(o, true); o += 8;
const altitude = view.getFloat32(o, true); o += 4;
const height = view.getFloat32(o, true); o += 4;
const sdGps = view.getUint8(o++);
const ampRatio = view.getUint8(o++);
const roll = view.getFloat32(o, true); o += 4;
const pitch = view.getFloat32(o, true); o += 4;
const yaw = view.getFloat32(o, true); o += 4;
const channelNum = view.getUint8(o++);
const current = view.getUint32(o, true); o += 4;
const temperature = view.getUint16(o, true); o += 2;
const batteryVolt = view.getUint16(o, true); o += 2;
const sourceMode = view.getUint8(o++);
return {
devId, utc, longitude, latitude, altitude, height,
sdStatus: sdGps & 0x0f,
gpsStatus: (sdGps >> 4) & 0x0f,
ampRatio, roll, pitch, yaw, channelNum,
current, temperature, batteryVolt, sourceMode,
};
}
// Parse ADC sample data from a 0x85/0x95 payload into per-channel arrays
export function parseAdcData(
payload: Uint8Array,
channelNum: number,
accNum: number,
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
const META_SIZE = 54;
const sampleDataLen = payload.length - META_SIZE;
const totalSamples = sampleDataLen / 4; // int32 per sample
const samplesPerChannel = Math.floor(totalSamples / channelNum);
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
const ampRatio = payload[9]; // ampRatio in metadata at offset 9 from payload start...
// actually re-derive from parseMetadata
const gain = AMP_GAIN[payload[9 + 1]] ?? 1; // byte 10 of payload (after devId+utc+lon+lat+alt+height+sdGps = 1+4+8+8+4+4+1 = 30)
// Correct: ampRatio is at offset 31 in payload (1+4+8+8+4+4+1 = 30, then ampRatio at 30)
const correctGain = AMP_GAIN[payload[30]] ?? 1;
const adcRaw: Int32Array[] = [];
const adcUV: Float64Array[] = [];
for (let ch = 0; ch < channelNum; ch++) {
const raw = new Int32Array(samplesPerChannel);
const uv = new Float64Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
const idx = (ch * samplesPerChannel + i) * 4;
const val = view.getInt32(idx, true);
raw[i] = val;
uv[i] = val / (accNum || 1) / correctGain;
}
adcRaw.push(raw);
adcUV.push(uv);
}
return { adcRaw, adcUV };
}
// Parse a generic ACK packet (0x810x84)
export function parseAck(payload: Uint8Array): AckPacket {
const result = payload[0] ?? 0x02;
let reason = '';
// reason string starts at offset 22 in the payload, max 32 chars
const reasonOffset = 22;
for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) {
if (payload[i] === 0) break;
reason += String.fromCharCode(payload[i]);
}
return { result, reason };
}

83
src/protocol/types.ts Normal file
View File

@ -0,0 +1,83 @@
// Raw parsed frame from the TCP stream
export interface RawFrame {
flag: number;
func: number;
payload: Uint8Array;
}
// Setup configuration sent to device (maps to setupReq_t)
export interface SetupConfig {
channelNum: number; // 16
sendFreq: number; // transmitter freq code
sampleFreq: number; // ADC sample freq code
sampleDepth: number; // points per cycle (uint16)
accNum: number; // stack count (14-bit, 016383)
negAcc456: boolean; // reverse stack ch4-6
negAcc123: boolean; // reverse stack ch1-3
ampRatio: number; // gain code
dataChannel: number; // 0x01=USB 0x02=P900 0x03=WiFi
compRes: number; // compensation resistor
secondSampleFreq: number; // secondary sample freq code
compDisableDelay: number; // delay × 50μs (uint16)
sourceMode: number; // source mode code
batteryVoltageMin: number;// min battery voltage (uint16)
filePrefix: string; // SD file prefix, max 15 chars
}
// Generic ACK from device
export interface AckPacket {
result: number; // 0x01=OK 0x02=FAIL
reason: string;
}
// Measurement metadata from device (first 54 bytes of 0x85 payload)
export interface MeasurementMeta {
devId: number;
utc: number; // seconds since epoch
longitude: number; // degrees
latitude: number; // degrees
altitude: number; // meters
height: number;
sdStatus: number; // bit[3:0]
gpsStatus: number; // bit[7:4] >> 4
ampRatio: number;
roll: number; // degrees
pitch: number; // degrees
yaw: number; // degrees
channelNum: number;
current: number; // peak TX current (raw)
temperature: number; // raw ADC
batteryVolt: number; // raw ADC
sourceMode: number;
}
// A complete measurement frame (single or multi-source)
export interface MeasurementFrame {
meta: MeasurementMeta;
// channelNum × sampleDepth matrix, indexed [channel][sample]
adcRaw: Int32Array[];
// Converted to μV: raw / accNum / gain
adcUV: Float64Array[];
accNum: number; // stack count at time of reception
gain: number; // gain multiplier at time of reception
timestamp: number;// local ms timestamp when received
frameId: number; // sequential frame counter
}
// Split frame session state
export interface SplitFrameSession {
active: boolean;
frameNo: number;
chunks: Uint8Array[];
}
// Connection state values
export type ConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'error';
// Device running state
export type DeviceStatus = 'idle' | 'running' | 'single';

View File

@ -0,0 +1,167 @@
import { tcpService } from './TcpService';
import {
buildSetupPacket,
buildContinuousReq,
buildSingleReq,
buildStopReq,
buildSplitFrameReq,
} from '../protocol/packet';
import {
parseMetadata,
parseAdcData,
parseAck,
} from '../protocol/parser';
import { FuncCode, SplitFrameCmd, AckResult } from '../protocol/constants';
import type { RawFrame, SetupConfig, MeasurementFrame, AckPacket } from '../protocol/types';
import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
let frameCounter = 0;
// Pending ACK promise resolver keyed by func code
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
// Split frame accumulation state
let splitChunks: Uint8Array[] = [];
let splitActive = false;
let splitFrameNo = 0;
export function handleIncomingFrame(frame: RawFrame) {
const { func, payload } = frame;
const devStore = useDeviceStore.getState();
const dataStore = useDataStore.getState();
switch (func) {
// ── ACK responses ──────────────────────────────────────────────
case FuncCode.SETUP_ACK:
case FuncCode.CONTINUOUS_ACK:
case FuncCode.SINGLE_ACK:
case FuncCode.STOP_ACK: {
const ack = parseAck(payload);
const resolver = pendingAcks.get(func);
if (resolver) {
pendingAcks.delete(func);
resolver(ack);
}
if (func === FuncCode.CONTINUOUS_ACK && ack.result === AckResult.OK) {
devStore.setDeviceStatus('running');
}
if (func === FuncCode.SINGLE_ACK && ack.result === AckResult.OK) {
devStore.setDeviceStatus('single');
}
if (func === FuncCode.STOP_ACK) {
devStore.setDeviceStatus('idle');
}
break;
}
// ── Measurement data (single or multi-source) ──────────────────
case FuncCode.DATA_ACK:
case FuncCode.COMP_DATA_ACK: {
processMeasurementPayload(payload, devStore, dataStore);
break;
}
// ── Split frame response ───────────────────────────────────────
case FuncCode.SPLITFRAME_ACK: {
const subCmd = payload[0];
// Data starts at byte 3 (subCmd + frameNo uint16)
const chunk = payload.slice(3);
splitChunks.push(chunk);
if (subCmd === SplitFrameCmd.LAST) {
// Reassemble and process
const total = splitChunks.reduce((s, c) => s + c.length, 0);
const assembled = new Uint8Array(total);
let off = 0;
for (const c of splitChunks) {
assembled.set(c, off);
off += c.length;
}
splitChunks = [];
splitActive = false;
processMeasurementPayload(assembled, devStore, dataStore);
} else {
// Request next frame
splitFrameNo++;
tcpService.send(buildSplitFrameReq(SplitFrameCmd.REQUEST, splitFrameNo));
}
break;
}
}
}
function processMeasurementPayload(
payload: Uint8Array,
devStore: ReturnType<typeof useDeviceStore.getState>,
dataStore: ReturnType<typeof useDataStore.getState>,
) {
const meta = parseMetadata(payload);
if (!meta) return;
const cfg = devStore.config;
const accNum = cfg.accNum || 1;
const { adcRaw, adcUV } = parseAdcData(payload, meta.channelNum, accNum);
const measurementFrame: MeasurementFrame = {
meta,
adcRaw,
adcUV,
accNum,
gain: cfg.ampRatio,
timestamp: Date.now(),
frameId: frameCounter++,
};
dataStore.addFrame(measurementFrame);
}
// ── Public API ──────────────────────────────────────────────────────────────
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingAcks.delete(ackFunc);
reject(new Error('ACK timeout'));
}, timeoutMs);
pendingAcks.set(ackFunc, (ack) => {
clearTimeout(timer);
resolve(ack);
});
const sent = tcpService.send(pkt);
if (!sent) {
clearTimeout(timer);
pendingAcks.delete(ackFunc);
reject(new Error('TCP not connected'));
}
});
}
export async function deviceSetup(config: SetupConfig): Promise<AckPacket> {
useDeviceStore.getState().setConfig(config);
return sendAndWaitAck(buildSetupPacket(config), FuncCode.SETUP_ACK);
}
export async function deviceStartContinuous(): Promise<AckPacket> {
return sendAndWaitAck(buildContinuousReq(), FuncCode.CONTINUOUS_ACK);
}
export async function deviceStartSingle(): Promise<AckPacket> {
return sendAndWaitAck(buildSingleReq(), FuncCode.SINGLE_ACK);
}
export async function deviceStop(): Promise<AckPacket> {
return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK);
}
// Initiate split frame transfer to request large data
export function deviceStartSplitFrame() {
if (splitActive) return;
splitActive = true;
splitFrameNo = 0;
splitChunks = [];
tcpService.send(buildSplitFrameReq(SplitFrameCmd.START, 0));
}

108
src/services/TcpService.ts Normal file
View File

@ -0,0 +1,108 @@
import TcpSocket from 'react-native-tcp-socket';
import { TemFrameParser } from '../protocol/parser';
import type { RawFrame } from '../protocol/types';
export type TcpCallbacks = {
onConnect: () => void;
onFrame: (frame: RawFrame) => void;
onError: (err: Error) => void;
onClose: () => void;
};
class TcpService {
private socket: ReturnType<typeof TcpSocket.createConnection> | null = null;
private parser = new TemFrameParser();
private callbacks: TcpCallbacks | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectEnabled = false;
private host = '';
private port = 0;
connect(host: string, port: number, callbacks: TcpCallbacks) {
this.host = host;
this.port = port;
this.callbacks = callbacks;
this.reconnectEnabled = true;
this.parser.reset();
this.createSocket();
}
private createSocket() {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
const sock = TcpSocket.createConnection(
{ host: this.host, port: this.port, tls: false },
() => {
this.connected = true;
clearTimeout(this.reconnectTimer!);
this.callbacks?.onConnect();
},
);
sock.on('data', (data: any) => {
const arr: Uint8Array =
data instanceof Uint8Array
? data
: data instanceof ArrayBuffer
? new Uint8Array(data)
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const frames = this.parser.feed(arr);
frames.forEach((f) => this.callbacks?.onFrame(f));
});
sock.on('error', (err: Error) => {
this.connected = false;
this.callbacks?.onError(err);
this.scheduleReconnect();
});
sock.on('close', () => {
this.connected = false;
this.callbacks?.onClose();
this.scheduleReconnect();
});
this.socket = sock;
}
private scheduleReconnect() {
if (!this.reconnectEnabled) return;
clearTimeout(this.reconnectTimer!);
this.reconnectTimer = setTimeout(() => {
if (this.reconnectEnabled) {
this.parser.reset();
this.createSocket();
}
}, 3000);
}
send(data: Uint8Array): boolean {
if (!this.connected || !this.socket) return false;
try {
this.socket.write(data as unknown as string);
return true;
} catch {
return false;
}
}
disconnect() {
this.reconnectEnabled = false;
clearTimeout(this.reconnectTimer!);
this.socket?.destroy();
this.socket = null;
this.connected = false;
this.parser.reset();
}
isConnected() {
return this.connected;
}
}
// Singleton instance shared across the app
export const tcpService = new TcpService();

View File

@ -0,0 +1,25 @@
import { create } from 'zustand';
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
import type { ConnectionStatus } from '../protocol/types';
interface ConnectionState {
status: ConnectionStatus;
host: string;
port: number;
lastError: string;
setStatus: (s: ConnectionStatus) => void;
setHost: (h: string) => void;
setPort: (p: number) => void;
setError: (e: string) => void;
}
export const useConnectionStore = create<ConnectionState>((set) => ({
status: 'disconnected',
host: DEFAULT_DEVICE_IP,
port: DEFAULT_TCP_PORT,
lastError: '',
setStatus: (status) => set({ status }),
setHost: (host) => set({ host }),
setPort: (port) => set({ port }),
setError: (lastError) => set({ lastError, status: 'error' }),
}));

35
src/stores/dataStore.ts Normal file
View File

@ -0,0 +1,35 @@
import { create } from 'zustand';
import type { MeasurementFrame } from '../protocol/types';
const MAX_HISTORY = 500; // keep last 500 frames in memory
interface DataState {
currentFrame: MeasurementFrame | null;
history: MeasurementFrame[]; // newest first
sessionId: string;
addFrame: (frame: MeasurementFrame) => void;
clearHistory: () => void;
newSession: () => void;
}
export const useDataStore = create<DataState>((set) => ({
currentFrame: null,
history: [],
sessionId: generateSessionId(),
addFrame: (frame) =>
set((s) => ({
currentFrame: frame,
history: [frame, ...s.history].slice(0, MAX_HISTORY),
})),
clearHistory: () => set({ history: [], currentFrame: null }),
newSession: () => set({ sessionId: generateSessionId(), history: [], currentFrame: null }),
}));
function generateSessionId(): string {
const now = new Date();
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}

59
src/stores/deviceStore.ts Normal file
View File

@ -0,0 +1,59 @@
import { create } from 'zustand';
import type { SetupConfig, DeviceStatus } from '../protocol/types';
import { DataChannel } from '../protocol/constants';
const DEFAULT_CONFIG: SetupConfig = {
channelNum: 3,
sendFreq: 0x03, // 4 Hz
sampleFreq: 0x03, // 31.25 kHz
sampleDepth: 1024,
accNum: 50,
negAcc456: false,
negAcc123: false,
ampRatio: 0x03, // 1×
dataChannel: DataChannel.WIFI,
compRes: 0,
secondSampleFreq: 0x03,
compDisableDelay: 300, // 300 × 50μs = 15ms
sourceMode: 0x00, // single source A
batteryVoltageMin: 0,
filePrefix: 'TEM',
};
interface DeviceState {
config: SetupConfig;
deviceStatus: DeviceStatus;
batteryVolt: number; // raw ADC
temperature: number; // raw ADC
gpsStatus: number;
sdStatus: number;
frameId: number; // last received frame ID
accCount: number; // stacking progress
setConfig: (cfg: SetupConfig) => void;
updateConfig: (partial: Partial<SetupConfig>) => void;
setDeviceStatus: (s: DeviceStatus) => void;
updateTelemetry: (data: {
batteryVolt?: number;
temperature?: number;
gpsStatus?: number;
sdStatus?: number;
frameId?: number;
accCount?: number;
}) => void;
}
export const useDeviceStore = create<DeviceState>((set) => ({
config: DEFAULT_CONFIG,
deviceStatus: 'idle',
batteryVolt: 0,
temperature: 0,
gpsStatus: 0,
sdStatus: 0,
frameId: 0,
accCount: 0,
setConfig: (config) => set({ config }),
updateConfig: (partial) =>
set((s) => ({ config: { ...s.config, ...partial } })),
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
}));

105
src/utils/export.ts Normal file
View File

@ -0,0 +1,105 @@
// Use legacy API for documentDirectory compatibility
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import type { MeasurementFrame } from '../protocol/types';
import { formatUtc, formatCoord } from './format';
const DATA_DIR = (FileSystem.documentDirectory ?? '') + 'tem_data/';
async function ensureDir() {
const info = await FileSystem.getInfoAsync(DATA_DIR);
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
}
// Build CSV content for a list of frames
function framesToCsv(frames: MeasurementFrame[]): string {
if (frames.length === 0) return '';
const channelNum = frames[0].meta.channelNum;
const chHeaders = Array.from({ length: channelNum }, (_, 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 = frames.map((f) => {
const m = f.meta;
const peaks = f.adcUV.map((ch) => {
const maxAbs = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
return maxAbs.toFixed(4);
});
return [
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(',');
});
return [header, ...rows].join('\n');
}
export async function exportCsv(frames: MeasurementFrame[], sessionId: string): Promise<string> {
await ensureDir();
const filename = `TEM_${sessionId}.csv`;
const path = DATA_DIR + filename;
const csv = framesToCsv(frames);
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
return path;
}
// Save a single frame's raw ADC data as binary file
export async function saveFrameBin(frame: MeasurementFrame, sessionId: string): Promise<string> {
await ensureDir();
const filename = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
const path = DATA_DIR + filename;
const channelNum = frame.adcRaw.length;
const samplesPerCh = frame.adcRaw[0]?.length ?? 0;
// Header: magic(4) + channelNum(4) + samplesPerCh(4) = 12 bytes
const totalBytes = 12 + channelNum * samplesPerCh * 4;
const buf = new ArrayBuffer(totalBytes);
const view = new DataView(buf);
// Magic: "TEM\x01"
view.setUint8(0, 0x54); view.setUint8(1, 0x45); view.setUint8(2, 0x4d); view.setUint8(3, 0x01);
view.setUint32(4, channelNum, true);
view.setUint32(8, samplesPerCh, true);
let offset = 12;
for (const ch of frame.adcRaw) {
for (let i = 0; i < ch.length; i++) {
view.setInt32(offset, ch[i], true);
offset += 4;
}
}
// Convert ArrayBuffer to base64 string for expo-file-system
const uint8 = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
const b64 = btoa(binary);
await FileSystem.writeAsStringAsync(path, b64, { encoding: FileSystem.EncodingType.Base64 });
return path;
}
export async function shareFile(filePath: string) {
const canShare = await Sharing.isAvailableAsync();
if (canShare) {
await Sharing.shareAsync(filePath);
}
}
export async function listSessionFiles(sessionId: string): Promise<string[]> {
await ensureDir();
const all = await FileSystem.readDirectoryAsync(DATA_DIR);
return all.filter((f) => f.startsWith(sessionId)).map((f) => DATA_DIR + f);
}

51
src/utils/format.ts Normal file
View File

@ -0,0 +1,51 @@
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
export function formatUV(uv: number): string {
const abs = Math.abs(uv);
if (abs >= 1e6) return `${(uv / 1e6).toFixed(2)} V`;
if (abs >= 1e3) return `${(uv / 1e3).toFixed(2)} mV`;
if (abs >= 1) return `${uv.toFixed(2)} μV`;
if (abs >= 1e-3) return `${(uv * 1e3).toFixed(2)} nV`;
return `${uv.toExponential(2)} μV`;
}
export function formatCoord(deg: number, isLon: boolean): string {
if (!isFinite(deg)) return '--';
const dir = isLon ? (deg >= 0 ? 'E' : 'W') : deg >= 0 ? 'N' : 'S';
return `${Math.abs(deg).toFixed(6)}°${dir}`;
}
export function formatUtc(utcSec: number): string {
if (!utcSec) return '--';
const d = new Date(utcSec * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
export function formatBattery(raw: number): string {
// raw ADC → voltage conversion depends on hardware; placeholder
const v = (raw / 4096) * 3.3 * 10; // rough estimate
return `${v.toFixed(1)} V`;
}
export function formatTemperature(raw: number): string {
// placeholder conversion
const c = raw / 10;
return `${c.toFixed(1)} °C`;
}
export function sendFreqLabel(code: number): string {
return SEND_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function sampleFreqLabel(code: number): string {
return SAMPLE_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function ampRatioLabel(code: number): string {
return AMP_RATIO_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function sourceModeLabel(code: number): string {
return SOURCE_MODE_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}