diff --git a/app.json b/app.json
index c5d6c5e..df0aeae 100644
--- a/app.json
+++ b/app.json
@@ -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": ""
}
]
],
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index c9d1e27..30bcd2e 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -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 ;
+}
export default function TabLayout() {
- const colorScheme = useColorScheme();
-
return (
+ headerShown: false,
+ tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
+ tabBarActiveTintColor: ACTIVE,
+ tabBarInactiveTintColor: INACTIVE,
+ tabBarLabelStyle: { fontSize: 10 },
+ }}
+ >
(
-
- ),
- headerRight: () => (
-
-
- {({ pressed }) => (
-
- )}
-
-
+
),
}}
/>
(
-
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
),
}}
/>
diff --git a/app/(tabs)/control.tsx b/app/(tabs)/control.tsx
new file mode 100644
index 0000000..a782500
--- /dev/null
+++ b/app/(tabs)/control.tsx
@@ -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 (
+
+ 未连接设备
+ router.replace('/connect')}>
+ 前往连接
+
+
+ );
+ }
+
+ const running = deviceStatus === 'running';
+ const canStart = !running && !busy;
+ const canStop = (running || deviceStatus === 'single') && !busy;
+
+ return (
+
+
+
+ {/* Control buttons */}
+
+
+ {busy ? : 连续采集}
+
+
+
+ 单次
+
+
+
+ 停止
+
+
+
+ {/* Last frame info */}
+ {frame && (
+
+
+ 帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
+
+
+ 电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
+
+
+ )}
+
+ {/* Param form */}
+
+
+ 采集参数
+ {configDirty && 未下发}
+
+ setConfigDirty(true)} />
+
+ {busy ? : 下 发 配 置}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0d0d0d' },
+ notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
+ notConnectedText: { color: '#666', fontSize: 16 },
+ connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
+ connectBtnText: { color: '#fff', fontWeight: '600' },
+ ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
+ 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 },
+});
diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx
new file mode 100644
index 0000000..462304a
--- /dev/null
+++ b/app/(tabs)/map.tsx
@@ -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(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 (
+
+
+ {/* GPS track polyline */}
+ {points.length > 1 && (
+
+ )}
+
+ {/* Measurement point markers */}
+ {points.map((p) => (
+
+
+
+ ))}
+
+
+ {/* Overlay info */}
+
+ 测点数: {points.length}
+ {latest && (
+
+ {formatCoord(latest.latitude, false)} {formatCoord(latest.longitude, true)}
+
+ )}
+
+
+ {/* Center button */}
+
+ ⌖
+
+
+ );
+}
+
+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 },
+});
diff --git a/app/(tabs)/records.tsx b/app/(tabs)/records.tsx
new file mode 100644
index 0000000..5223e60
--- /dev/null
+++ b/app/(tabs)/records.tsx
@@ -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 (
+
+
+ #{item.frameId}
+ {m ? formatUtc(m.utc) : '--'}
+
+ {m && (
+ <>
+
+ {formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
+
+
+ 叠加 {item.accNum}次
+ 增益 ×{item.gain}
+ CH1峰值 {formatUV(peakUV)}
+ 0 ? '#1a4a1a' : '#2a1a1a' }]}>
+ 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
+ GPS {m.gpsStatus > 0 ? '✓' : '✗'}
+
+
+
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+ 会话: {sessionId}
+ {history.length} 测点
+
+
+ {exporting ? (
+
+ ) : (
+ 导出 CSV
+ )}
+
+
+ 清空
+
+
+
+
+ {history.length === 0 ? (
+
+ 暂无采集记录
+ 在控制台开始采集后,数据将显示在这里
+
+ ) : (
+ String(item.frameId)}
+ renderItem={renderItem}
+ contentContainerStyle={styles.list}
+ ItemSeparatorComponent={() => }
+ />
+ )}
+
+ );
+}
+
+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 },
+});
diff --git a/app/(tabs)/waveform.tsx b/app/(tabs)/waveform.tsx
new file mode 100644
index 0000000..e253898
--- /dev/null
+++ b/app/(tabs)/waveform.tsx
@@ -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 (
+
+
+
+
+
+ {/* Waveform chart */}
+
+ {data ? (
+
+ ) : (
+
+ 等待采样数据...
+
+ )}
+
+
+ {/* Channel peak values */}
+ {frame && (
+
+
+ {frame.adcUV.map((ch, idx) => {
+ if (!visible[idx] || idx >= channelNum) return null;
+ const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
+ const peak = ch[0] < 0 ? -absMax : absMax;
+ return (
+
+ CH{idx + 1}
+ {formatUV(absMax)}
+
+ );
+ })}
+
+
+ {/* Frame metadata */}
+ {frame.meta && (
+
+
+ 帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
+
+
+ {formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
+
+
+ Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
+
+
+ )}
+
+ )}
+
+ );
+}
+
+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' },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 8925669..f17420c 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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 ;
-}
-
-function RootLayoutNav() {
- const colorScheme = useColorScheme();
+ SplashScreen.hideAsync();
+ }, []);
return (
-
-
-
-
+
+
+
+
+
+
-
+
);
}
+
+const styles = StyleSheet.create({ root: { flex: 1 } });
diff --git a/app/connect.tsx b/app/connect.tsx
new file mode 100644
index 0000000..0139c61
--- /dev/null
+++ b/app/connect.tsx
@@ -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(['准备连接...']);
+ 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 (
+
+ TEM Receiver
+ 瞬变电磁接收机上位机
+
+ {/* Connection steps */}
+
+ 连接步骤
+ ① 在手机 WiFi 设置中连接设备热点
+ Linking.openSettings()}>
+ 打开 WiFi 设置 →
+
+ ② 确认连接参数后点击连接
+
+
+ {/* IP / Port input */}
+
+
+ 设备 IP
+
+
+
+ 端口
+
+
+
+
+ {/* Connect / Disconnect button */}
+ {!isConnected ? (
+
+ {isConnecting ? (
+
+ ) : (
+ 连 接 设 备
+ )}
+
+ ) : (
+
+
+
+ 已连接
+
+ router.replace('/(tabs)/control')}>
+ 进入控制台 →
+
+
+ 断开
+
+
+ )}
+
+ {/* Connection log */}
+
+ 连接日志
+
+ {logs.map((l, i) => (
+ {l}
+ ))}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
+ title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
+ subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
+ 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 },
+});
diff --git a/package-lock.json b/package-lock.json
index 10e28a3..bae56e8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 88d5c26..5a30fbc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/ChannelSelector.tsx b/src/components/ChannelSelector.tsx
new file mode 100644
index 0000000..978045f
--- /dev/null
+++ b/src/components/ChannelSelector.tsx
@@ -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 (
+
+ {Array.from({ length: maxChannels }, (_, i) => (
+ onToggle(i)}
+ activeOpacity={0.7}
+ >
+
+
+ CH{i + 1}
+
+
+ ))}
+
+ );
+}
+
+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' },
+});
diff --git a/src/components/DeviceStatusBar.tsx b/src/components/DeviceStatusBar.tsx
new file mode 100644
index 0000000..9baecba
--- /dev/null
+++ b/src/components/DeviceStatusBar.tsx
@@ -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 (
+
+
+ {label}
+ {value}
+
+ );
+}
+
+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 (
+
+
+
+ {statusLabel}
+
+
+
+ 0 ? 'OK' : '--'} ok={sdStatus > 0} />
+
+
+
+ );
+}
+
+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' },
+});
diff --git a/src/components/ParamForm.tsx b/src/components/ParamForm.tsx
new file mode 100644
index 0000000..5783d49
--- /dev/null
+++ b/src/components/ParamForm.tsx
@@ -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 (
+
+ {label}
+
+ {options.map((o) => (
+ onChange(o.code)}
+ >
+
+ {o.label}
+
+
+ ))}
+
+
+ );
+}
+
+interface NumberRowProps {
+ label: string;
+ value: number;
+ onChangeText: (v: string) => void;
+ suffix?: string;
+ keyboardType?: 'numeric';
+}
+
+function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
+ return (
+
+ {label}
+
+
+ {suffix && {suffix}}
+
+
+ );
+}
+
+interface FormProps {
+ onAnyChange?: () => void;
+}
+
+export function ParamForm({ onAnyChange }: FormProps = {}) {
+ const { config, updateConfig } = useDeviceStore();
+ const patch = (partial: Partial) => {
+ updateConfig(partial);
+ onAnyChange?.();
+ };
+
+ return (
+
+ {/* Channel selector */}
+
+ 通道数
+
+ {[1, 2, 3, 4, 5, 6].map((n) => (
+ patch({ channelNum: n })}
+ >
+
+ {n}
+
+
+ ))}
+
+
+
+ patch({ sendFreq: v })}
+ />
+
+ patch({ sampleFreq: v })}
+ />
+
+ patch({ sampleDepth: parseInt(v, 10) || 0 })}
+ />
+
+ patch({ accNum: parseInt(v, 10) || 0 })}
+ />
+
+ patch({ ampRatio: v })}
+ />
+
+ patch({ sourceMode: v })}
+ />
+
+ {/* Reverse accumulation flags */}
+
+ 反向叠加
+
+ CH1-3
+ patch({ negAcc123: v })}
+ thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
+ trackColor={{ false: '#333', true: '#1a4a88' }}
+ />
+ CH4-6
+ patch({ negAcc456: v })}
+ thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
+ trackColor={{ false: '#333', true: '#1a4a88' }}
+ />
+
+
+
+ patch({ compDisableDelay: parseInt(v, 10) || 0 })}
+ suffix="×50μs"
+ />
+
+ {/* File prefix */}
+
+ 文件前缀
+
+ patch({ filePrefix: v.slice(0, 15) })}
+ maxLength={15}
+ autoCapitalize="none"
+ placeholderTextColor="#555"
+ />
+
+
+
+ );
+}
+
+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 },
+});
diff --git a/src/components/WaveformChart.tsx b/src/components/WaveformChart.tsx
new file mode 100644
index 0000000..68bd73f
--- /dev/null
+++ b/src/components/WaveformChart.tsx
@@ -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 (
+
+
+
+ {/* 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 (
+
+ {label}
+
+ );
+ })}
+
+ {/* X-axis label */}
+
+ 时间 →
+
+
+ {/* Y-axis unit */}
+ μV
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/hooks/useDevice.ts b/src/hooks/useDevice.ts
new file mode 100644
index 0000000..3a1a5f0
--- /dev/null
+++ b/src/hooks/useDevice.ts
@@ -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,
+ };
+}
diff --git a/src/hooks/useWaveform.ts b/src/hooks/useWaveform.ts
new file mode 100644
index 0000000..35de54c
--- /dev/null
+++ b/src/hooks/useWaveform.ts
@@ -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 0–1 for now, App can scale by actual period
+ const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
+ const timeMs = new Float64Array(displayLen);
+ for (let i = 0; i < displayLen; i++) {
+ timeMs[i] = i / displayLen; // normalised 0–1 (multiply by period in ms externally)
+ }
+
+ let globalMin = Infinity;
+ let globalMax = -Infinity;
+
+ const channels: Float64Array[] = adcUV.map((ch, idx) => {
+ if (!visibleChannels[idx]) return new Float64Array(displayLen);
+ const ds = downsample(ch, displayLen);
+ for (let i = 0; i < ds.length; i++) {
+ const absV = Math.abs(ds[i]);
+ if (absV > 1e-9) {
+ globalMin = Math.min(globalMin, absV);
+ globalMax = Math.max(globalMax, absV);
+ }
+ }
+ return ds;
+ });
+
+ if (!isFinite(globalMin)) globalMin = 1e-3;
+ if (!isFinite(globalMax)) globalMax = 1e6;
+
+ return {
+ channels,
+ timeMs,
+ minLogUV: Math.log10(globalMin),
+ maxLogUV: Math.log10(globalMax),
+ sampleDepth,
+ };
+ }, [frame, visibleChannels]);
+}
diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts
new file mode 100644
index 0000000..483f720
--- /dev/null
+++ b/src/protocol/constants.ts
@@ -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;
diff --git a/src/protocol/packet.ts b/src/protocol/packet.ts
new file mode 100644
index 0000000..d66fa51
--- /dev/null
+++ b/src/protocol/packet.ts
@@ -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;
+}
diff --git a/src/protocol/parser.ts b/src/protocol/parser.ts
new file mode 100644
index 0000000..4a474e4
--- /dev/null
+++ b/src/protocol/parser.ts
@@ -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 (0x81–0x84)
+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 };
+}
diff --git a/src/protocol/types.ts b/src/protocol/types.ts
new file mode 100644
index 0000000..3478fef
--- /dev/null
+++ b/src/protocol/types.ts
@@ -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; // 1–6
+ sendFreq: number; // transmitter freq code
+ sampleFreq: number; // ADC sample freq code
+ sampleDepth: number; // points per cycle (uint16)
+ accNum: number; // stack count (14-bit, 0–16383)
+ 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';
diff --git a/src/services/DeviceService.ts b/src/services/DeviceService.ts
new file mode 100644
index 0000000..17bd881
--- /dev/null
+++ b/src/services/DeviceService.ts
@@ -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 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,
+ dataStore: ReturnType,
+) {
+ 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 {
+ 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 {
+ useDeviceStore.getState().setConfig(config);
+ return sendAndWaitAck(buildSetupPacket(config), FuncCode.SETUP_ACK);
+}
+
+export async function deviceStartContinuous(): Promise {
+ return sendAndWaitAck(buildContinuousReq(), FuncCode.CONTINUOUS_ACK);
+}
+
+export async function deviceStartSingle(): Promise {
+ return sendAndWaitAck(buildSingleReq(), FuncCode.SINGLE_ACK);
+}
+
+export async function deviceStop(): Promise {
+ 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));
+}
diff --git a/src/services/TcpService.ts b/src/services/TcpService.ts
new file mode 100644
index 0000000..b0cea7b
--- /dev/null
+++ b/src/services/TcpService.ts
@@ -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 | null = null;
+ private parser = new TemFrameParser();
+ private callbacks: TcpCallbacks | null = null;
+ private connected = false;
+ private reconnectTimer: ReturnType | 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();
diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts
new file mode 100644
index 0000000..65d1d14
--- /dev/null
+++ b/src/stores/connectionStore.ts
@@ -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((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' }),
+}));
diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts
new file mode 100644
index 0000000..730a9cc
--- /dev/null
+++ b/src/stores/dataStore.ts
@@ -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((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())}`
+ );
+}
diff --git a/src/stores/deviceStore.ts b/src/stores/deviceStore.ts
new file mode 100644
index 0000000..df1f86f
--- /dev/null
+++ b/src/stores/deviceStore.ts
@@ -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) => void;
+ setDeviceStatus: (s: DeviceStatus) => void;
+ updateTelemetry: (data: {
+ batteryVolt?: number;
+ temperature?: number;
+ gpsStatus?: number;
+ sdStatus?: number;
+ frameId?: number;
+ accCount?: number;
+ }) => void;
+}
+
+export const useDeviceStore = create((set) => ({
+ config: DEFAULT_CONFIG,
+ deviceStatus: 'idle',
+ batteryVolt: 0,
+ temperature: 0,
+ gpsStatus: 0,
+ sdStatus: 0,
+ frameId: 0,
+ accCount: 0,
+ setConfig: (config) => set({ config }),
+ updateConfig: (partial) =>
+ set((s) => ({ config: { ...s.config, ...partial } })),
+ setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
+ updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
+}));
diff --git a/src/utils/export.ts b/src/utils/export.ts
new file mode 100644
index 0000000..f60c7cc
--- /dev/null
+++ b/src/utils/export.ts
@@ -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 {
+ 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 {
+ 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 {
+ await ensureDir();
+ const all = await FileSystem.readDirectoryAsync(DATA_DIR);
+ return all.filter((f) => f.startsWith(sessionId)).map((f) => DATA_DIR + f);
+}
diff --git a/src/utils/format.ts b/src/utils/format.ts
new file mode 100644
index 0000000..12e6cf4
--- /dev/null
+++ b/src/utils/format.ts
@@ -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)}`;
+}