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 ( + + + {/* Background */} + + {/* Y-axis grid lines */} + {yGridLines.map((v) => { + const y = toY(v); + return ( + + ); + })} + + {/* X-axis baseline */} + + + {/* Channel waveform paths */} + {channelPaths.map((path, idx) => { + if (!path || !visibleChannels[idx]) return null; + 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)}`; +}