init
This commit is contained in:
parent
ed8682a1e2
commit
091b358705
39
app.json
39
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": ""
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@ -1,67 +1,60 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import { Link, Tabs } from 'expo-router';
|
||||
import { Platform, Pressable } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
||||
const ACTIVE = '#4a9eff';
|
||||
const INACTIVE = '#555';
|
||||
const BG = '#111111';
|
||||
|
||||
// SymbolView tintColor is typed as string but Tabs passes ColorValue — cast to avoid TS error
|
||||
function TabIcon({ name, color }: { name: string; color: any }) {
|
||||
return <SymbolView name={name as any} tintColor={color} size={22} />;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||
// Disable the static render of the header on web
|
||||
// to prevent a hydration error in React Navigation v6.
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Tab One',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: 'chevron.left.forwardslash.chevron.right',
|
||||
android: 'code',
|
||||
web: 'code',
|
||||
headerShown: false,
|
||||
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
|
||||
tabBarActiveTintColor: ACTIVE,
|
||||
tabBarInactiveTintColor: INACTIVE,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
}}
|
||||
tintColor={color}
|
||||
size={28}
|
||||
/>
|
||||
),
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<Pressable style={{ marginRight: 15 }}>
|
||||
{({ pressed }) => (
|
||||
<SymbolView
|
||||
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
|
||||
size={25}
|
||||
tintColor={Colors[colorScheme].text}
|
||||
style={{ opacity: pressed ? 0.5 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Link>
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="control"
|
||||
options={{
|
||||
title: '控制台',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
name="waveform"
|
||||
options={{
|
||||
title: 'Tab Two',
|
||||
title: '波形',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: 'chevron.left.forwardslash.chevron.right',
|
||||
android: 'code',
|
||||
web: 'code',
|
||||
<TabIcon name={Platform.select({ ios: 'waveform', android: 'show_chart', web: 'show_chart' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
tintColor={color}
|
||||
size={28}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="map"
|
||||
options={{
|
||||
title: '地图',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabIcon name={Platform.select({ ios: 'map', android: 'map', web: 'map' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="records"
|
||||
options={{
|
||||
title: '记录',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabIcon name={Platform.select({ ios: 'list.bullet', android: 'list', web: 'list' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
150
app/(tabs)/control.tsx
Normal file
150
app/(tabs)/control.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||
import { ParamForm } from '../../src/components/ParamForm';
|
||||
import { useDevice } from '../../src/hooks/useDevice';
|
||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
|
||||
export default function ControlScreen() {
|
||||
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const [configDirty, setConfigDirty] = useState(false);
|
||||
|
||||
const handleSetup = async () => {
|
||||
await setup();
|
||||
setConfigDirty(false);
|
||||
};
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<View style={styles.notConnected}>
|
||||
<Text style={styles.notConnectedText}>未连接设备</Text>
|
||||
<TouchableOpacity style={styles.connectBtn} onPress={() => router.replace('/connect')}>
|
||||
<Text style={styles.connectBtnText}>前往连接</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const running = deviceStatus === 'running';
|
||||
const canStart = !running && !busy;
|
||||
const canStop = (running || deviceStatus === 'single') && !busy;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DeviceStatusBar />
|
||||
|
||||
{/* Control buttons */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
|
||||
onPress={startContinuous}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}>连续采集</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
|
||||
onPress={startSingle}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Text style={styles.ctrlText}>单次</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
|
||||
onPress={stop}
|
||||
disabled={!canStop}
|
||||
>
|
||||
<Text style={styles.ctrlText}>停止</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Last frame info */}
|
||||
{frame && (
|
||||
<View style={styles.frameInfo}>
|
||||
<Text style={styles.frameText}>
|
||||
帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
|
||||
</Text>
|
||||
<Text style={styles.frameText}>
|
||||
电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Param form */}
|
||||
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.formHeader}>
|
||||
<Text style={styles.formTitle}>采集参数</Text>
|
||||
{configDirty && <Text style={styles.dirtyBadge}>未下发</Text>}
|
||||
</View>
|
||||
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
||||
<TouchableOpacity
|
||||
style={[styles.setupBtn, busy && styles.btnDisabled]}
|
||||
onPress={handleSetup}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}>下 发 配 置</Text>}
|
||||
</TouchableOpacity>
|
||||
<View style={{ height: 24 }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
|
||||
notConnectedText: { color: '#666', fontSize: 16 },
|
||||
connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
|
||||
connectBtnText: { color: '#fff', fontWeight: '600' },
|
||||
ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
|
||||
ctrlBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btnStart: { backgroundColor: '#1a5c2a' },
|
||||
btnSingle: { backgroundColor: '#1a3a5c' },
|
||||
btnStop: { backgroundColor: '#5c1a1a' },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||||
frameInfo: {
|
||||
marginHorizontal: 12,
|
||||
backgroundColor: '#181818',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
|
||||
formScroll: { flex: 1 },
|
||||
formHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
formTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600' },
|
||||
dirtyBadge: {
|
||||
backgroundColor: '#5c3a0a',
|
||||
color: '#ffaa00',
|
||||
fontSize: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
setupBtn: {
|
||||
margin: 14,
|
||||
backgroundColor: '#4a9eff',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
|
||||
});
|
||||
114
app/(tabs)/map.tsx
Normal file
114
app/(tabs)/map.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
import MapView, { Marker, Polyline, type Region } from 'react-native-maps';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { formatCoord } from '../../src/utils/format';
|
||||
|
||||
export default function MapScreen() {
|
||||
const history = useDataStore((s) => s.history);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const points = history
|
||||
.filter((f) => f.meta && f.meta.latitude !== 0 && f.meta.longitude !== 0)
|
||||
.map((f) => ({
|
||||
latitude: f.meta!.latitude,
|
||||
longitude: f.meta!.longitude,
|
||||
frameId: f.frameId,
|
||||
}))
|
||||
.reverse(); // oldest first for polyline
|
||||
|
||||
const latest = points[points.length - 1];
|
||||
|
||||
const centerOnCurrent = () => {
|
||||
if (!latest || !mapRef.current) return;
|
||||
mapRef.current.animateToRegion(
|
||||
{ latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.005, longitudeDelta: 0.005 },
|
||||
600,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
style={styles.map}
|
||||
mapType="satellite"
|
||||
showsUserLocation
|
||||
initialRegion={
|
||||
latest
|
||||
? { latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
|
||||
: { latitude: 30, longitude: 115, latitudeDelta: 10, longitudeDelta: 10 }
|
||||
}
|
||||
>
|
||||
{/* GPS track polyline */}
|
||||
{points.length > 1 && (
|
||||
<Polyline coordinates={points} strokeColor="#4a9eff" strokeWidth={2} />
|
||||
)}
|
||||
|
||||
{/* Measurement point markers */}
|
||||
{points.map((p) => (
|
||||
<Marker
|
||||
key={p.frameId}
|
||||
coordinate={{ latitude: p.latitude, longitude: p.longitude }}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
title={`帧 #${p.frameId}`}
|
||||
>
|
||||
<View style={styles.markerDot} />
|
||||
</Marker>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
{/* Overlay info */}
|
||||
<View style={styles.overlay}>
|
||||
<Text style={styles.overlayText}>测点数: {points.length}</Text>
|
||||
{latest && (
|
||||
<Text style={styles.overlayText}>
|
||||
{formatCoord(latest.latitude, false)} {formatCoord(latest.longitude, true)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Center button */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
|
||||
<Text style={styles.centerBtnText}>⌖</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
markerDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#4a9eff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
gap: 2,
|
||||
},
|
||||
overlayText: { color: '#ddd', fontSize: 11 },
|
||||
centerBtn: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(10,10,10,0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
},
|
||||
centerBtnText: { color: '#4a9eff', fontSize: 22 },
|
||||
});
|
||||
159
app/(tabs)/records.tsx
Normal file
159
app/(tabs)/records.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { exportCsv, shareFile } from '../../src/utils/export';
|
||||
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
|
||||
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||
|
||||
export default function RecordsScreen() {
|
||||
const { history, sessionId, clearHistory } = useDataStore();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (history.length === 0) {
|
||||
Alert.alert('无数据', '当前会话没有采集记录');
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const path = await exportCsv(history, sessionId);
|
||||
await shareFile(path);
|
||||
} catch (e: any) {
|
||||
Alert.alert('导出失败', e.message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '清空', style: 'destructive', onPress: clearHistory },
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: MeasurementFrame }) => {
|
||||
const m = item.meta;
|
||||
const peakUV = item.adcUV[0]
|
||||
? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<View style={styles.item}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.frameId}>#{item.frameId}</Text>
|
||||
<Text style={styles.time}>{m ? formatUtc(m.utc) : '--'}</Text>
|
||||
</View>
|
||||
{m && (
|
||||
<>
|
||||
<Text style={styles.coord}>
|
||||
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
|
||||
</Text>
|
||||
<View style={styles.itemFooter}>
|
||||
<Text style={styles.meta}>叠加 {item.accNum}次</Text>
|
||||
<Text style={styles.meta}>增益 ×{item.gain}</Text>
|
||||
<Text style={styles.meta}>CH1峰值 {formatUV(peakUV)}</Text>
|
||||
<View style={[styles.gpsIndicator, { backgroundColor: m.gpsStatus > 0 ? '#1a4a1a' : '#2a1a1a' }]}>
|
||||
<Text style={{ color: m.gpsStatus > 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
|
||||
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Toolbar */}
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.sessionText}>会话: {sessionId}</Text>
|
||||
<Text style={styles.countText}>{history.length} 测点</Text>
|
||||
<View style={styles.toolbarBtns}>
|
||||
<TouchableOpacity
|
||||
style={[styles.toolBtn, styles.exportBtn, exporting && styles.btnDisabled]}
|
||||
onPress={handleExportAll}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? (
|
||||
<ActivityIndicator color="#44cc44" size="small" />
|
||||
) : (
|
||||
<Text style={styles.exportText}>导出 CSV</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.toolBtn, styles.clearBtn]} onPress={handleClear}>
|
||||
<Text style={styles.clearText}>清空</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>暂无采集记录</Text>
|
||||
<Text style={styles.emptyHint}>在控制台开始采集后,数据将显示在这里</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={(item) => String(item.frameId)}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
backgroundColor: '#111',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#2a2a2a',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
sessionText: { color: '#555', fontSize: 10, flex: 1 },
|
||||
countText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
||||
toolbarBtns: { flexDirection: 'row', gap: 8 },
|
||||
toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
|
||||
exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
|
||||
exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
|
||||
clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
|
||||
clearText: { color: '#ff6666', fontSize: 12 },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
list: { padding: 10, gap: 8 },
|
||||
item: {
|
||||
backgroundColor: '#181818',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
},
|
||||
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
|
||||
time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
|
||||
coord: { color: '#888', fontSize: 10, marginBottom: 4 },
|
||||
itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
|
||||
meta: { color: '#555', fontSize: 10 },
|
||||
gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
|
||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
|
||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
|
||||
emptyText: { color: '#444', fontSize: 16 },
|
||||
emptyHint: { color: '#333', fontSize: 12 },
|
||||
});
|
||||
114
app/(tabs)/waveform.tsx
Normal file
114
app/(tabs)/waveform.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
|
||||
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||
import { WaveformChart } from '../../src/components/WaveformChart';
|
||||
import { ChannelSelector } from '../../src/components/ChannelSelector';
|
||||
import { useWaveform } from '../../src/hooks/useWaveform';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||
|
||||
export default function WaveformScreen() {
|
||||
const { width } = useWindowDimensions();
|
||||
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
||||
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const data = useWaveform(visible);
|
||||
|
||||
const toggleChannel = (idx: number) => {
|
||||
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
|
||||
};
|
||||
|
||||
const CHART_HEIGHT = 280;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DeviceStatusBar />
|
||||
|
||||
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
||||
|
||||
{/* Waveform chart */}
|
||||
<View style={styles.chartWrap}>
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
visibleChannels={visible}
|
||||
width={width}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
|
||||
<Text style={styles.placeholderText}>等待采样数据...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Channel peak values */}
|
||||
{frame && (
|
||||
<ScrollView style={styles.peakScroll}>
|
||||
<View style={styles.peakRow}>
|
||||
{frame.adcUV.map((ch, idx) => {
|
||||
if (!visible[idx] || idx >= channelNum) return null;
|
||||
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||
const peak = ch[0] < 0 ? -absMax : absMax;
|
||||
return (
|
||||
<View key={idx} style={styles.peakCell}>
|
||||
<Text style={styles.peakLabel}>CH{idx + 1}</Text>
|
||||
<Text style={styles.peakValue}>{formatUV(absMax)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Frame metadata */}
|
||||
{frame.meta && (
|
||||
<View style={styles.metaBlock}>
|
||||
<Text style={styles.metaText}>
|
||||
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
||||
</Text>
|
||||
<Text style={styles.metaText}>
|
||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
||||
</Text>
|
||||
<Text style={styles.metaText}>
|
||||
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
||||
placeholder: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: { color: '#444', fontSize: 14 },
|
||||
peakScroll: { flex: 1 },
|
||||
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
||||
peakCell: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
},
|
||||
peakLabel: { color: '#888', fontSize: 11 },
|
||||
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
||||
metaBlock: {
|
||||
margin: 12,
|
||||
backgroundColor: '#111',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
gap: 3,
|
||||
},
|
||||
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
|
||||
});
|
||||
@ -1,56 +1,32 @@
|
||||
import { useFonts } from 'expo-font';
|
||||
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export { ErrorBoundary } from 'expo-router';
|
||||
|
||||
export const unstable_settings = { initialRouteName: 'connect' };
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#0d0d0d' } }}>
|
||||
<Stack.Screen name="connect" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ root: { flex: 1 } });
|
||||
|
||||
207
app/connect.tsx
Normal file
207
app/connect.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useConnectionStore } from '../src/stores/connectionStore';
|
||||
import { useDevice } from '../src/hooks/useDevice';
|
||||
|
||||
const LOG_MAX = 60;
|
||||
|
||||
export default function ConnectScreen() {
|
||||
const { host, port, status, setHost, setPort } = useConnectionStore();
|
||||
const { connect, disconnect } = useDevice();
|
||||
const [logs, setLogs] = useState<string[]>(['准备连接...']);
|
||||
const [portStr, setPortStr] = useState(String(port));
|
||||
|
||||
const addLog = (msg: string) => {
|
||||
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
const p = parseInt(portStr, 10);
|
||||
if (!host || isNaN(p)) {
|
||||
addLog('请检查 IP 和端口');
|
||||
return;
|
||||
}
|
||||
setPort(p);
|
||||
addLog(`正在连接 ${host}:${p}...`);
|
||||
|
||||
// Watch for status changes via the hook (side effect in useDevice)
|
||||
useConnectionStore.subscribe((s) => {
|
||||
if (s.status === 'connected') {
|
||||
addLog('连接成功!');
|
||||
router.replace('/(tabs)/control');
|
||||
} else if (s.status === 'error') {
|
||||
addLog(`错误: ${s.lastError}`);
|
||||
} else if (s.status === 'reconnecting') {
|
||||
addLog('断线,正在重连...');
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
disconnect();
|
||||
addLog('已断开连接');
|
||||
};
|
||||
|
||||
const isConnecting = status === 'connecting' || status === 'reconnecting';
|
||||
const isConnected = status === 'connected';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>TEM Receiver</Text>
|
||||
<Text style={styles.subtitle}>瞬变电磁接收机上位机</Text>
|
||||
|
||||
{/* Connection steps */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>连接步骤</Text>
|
||||
<Text style={styles.step}>① 在手机 WiFi 设置中连接设备热点</Text>
|
||||
<TouchableOpacity style={styles.wifiBtn} onPress={() => Linking.openSettings()}>
|
||||
<Text style={styles.wifiBtnText}>打开 WiFi 设置 →</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.step}>② 确认连接参数后点击连接</Text>
|
||||
</View>
|
||||
|
||||
{/* IP / Port input */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.inputRow}>
|
||||
<Text style={styles.inputLabel}>设备 IP</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
keyboardType="numeric"
|
||||
placeholder="192.168.4.1"
|
||||
placeholderTextColor="#555"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputRow}>
|
||||
<Text style={styles.inputLabel}>端口</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={portStr}
|
||||
onChangeText={setPortStr}
|
||||
keyboardType="numeric"
|
||||
placeholder="4321"
|
||||
placeholderTextColor="#555"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Connect / Disconnect button */}
|
||||
{!isConnected ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.connectBtn, isConnecting && styles.connectBtnBusy]}
|
||||
onPress={handleConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.connectBtnText}>连 接 设 备</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.connectedRow}>
|
||||
<View style={styles.connectedBadge}>
|
||||
<View style={styles.greenDot} />
|
||||
<Text style={styles.connectedText}>已连接</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.goBtn} onPress={() => router.replace('/(tabs)/control')}>
|
||||
<Text style={styles.goBtnText}>进入控制台 →</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
|
||||
<Text style={styles.disconnectBtnText}>断开</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Connection log */}
|
||||
<View style={styles.logCard}>
|
||||
<Text style={styles.logTitle}>连接日志</Text>
|
||||
<ScrollView style={styles.logScroll} nestedScrollEnabled>
|
||||
{logs.map((l, i) => (
|
||||
<Text key={i} style={styles.logLine}>{l}</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
|
||||
title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
|
||||
subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
|
||||
card: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
},
|
||||
cardTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600', marginBottom: 8 },
|
||||
step: { color: '#bbb', fontSize: 13, marginBottom: 6 },
|
||||
wifiBtn: {
|
||||
backgroundColor: '#0d2b55',
|
||||
borderRadius: 6,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: 10,
|
||||
},
|
||||
wifiBtnText: { color: '#4a9eff', fontSize: 12 },
|
||||
inputRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||
inputLabel: { color: '#888', fontSize: 13, width: 50 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: '#111',
|
||||
color: '#eee',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
connectBtn: {
|
||||
backgroundColor: '#4a9eff',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
connectBtnBusy: { backgroundColor: '#2a5a99' },
|
||||
connectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: 2 },
|
||||
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
|
||||
connectedBadge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
greenDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#44cc44' },
|
||||
connectedText: { color: '#44cc44', fontWeight: '600' },
|
||||
goBtn: { flex: 1, backgroundColor: '#1a4a22', borderRadius: 8, padding: 10, alignItems: 'center' },
|
||||
goBtnText: { color: '#44cc44', fontWeight: '600' },
|
||||
disconnectBtn: { backgroundColor: '#3a1010', borderRadius: 8, padding: 10 },
|
||||
disconnectBtnText: { color: '#ff6666' },
|
||||
logCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#111',
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#222',
|
||||
},
|
||||
logTitle: { color: '#666', fontSize: 11, marginBottom: 4 },
|
||||
logScroll: { flex: 1 },
|
||||
logLine: { color: '#555', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 },
|
||||
});
|
||||
465
package-lock.json
generated
465
package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
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",
|
||||
|
||||
44
src/components/ChannelSelector.tsx
Normal file
44
src/components/ChannelSelector.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { CHANNEL_COLORS } from '../protocol/constants';
|
||||
|
||||
interface Props {
|
||||
maxChannels: number;
|
||||
visible: boolean[];
|
||||
onToggle: (idx: number) => void;
|
||||
}
|
||||
|
||||
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{Array.from({ length: maxChannels }, (_, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.chip, { borderColor: CHANNEL_COLORS[i], opacity: visible[i] ? 1 : 0.35 }]}
|
||||
onPress={() => onToggle(i)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
||||
<Text style={[styles.label, { color: visible[i] ? CHANNEL_COLORS[i] : '#888' }]}>
|
||||
CH{i + 1}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
gap: 4,
|
||||
},
|
||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||
label: { fontSize: 11, fontWeight: '600' },
|
||||
});
|
||||
73
src/components/DeviceStatusBar.tsx
Normal file
73
src/components/DeviceStatusBar.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
import { formatBattery, formatTemperature, formatCoord } from '../utils/format';
|
||||
|
||||
function Indicator({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||
return (
|
||||
<View style={styles.indicator}>
|
||||
<View style={[styles.dot, { backgroundColor: ok === false ? '#ff4444' : ok ? '#44cc44' : '#888888' }]} />
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceStatusBar() {
|
||||
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const meta = frame?.meta;
|
||||
|
||||
const lat = meta?.latitude ?? 0;
|
||||
const lon = meta?.longitude ?? 0;
|
||||
const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
|
||||
const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
|
||||
|
||||
const running = deviceStatus === 'running';
|
||||
const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
|
||||
const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
|
||||
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<View style={[styles.statusBadge, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
||||
</View>
|
||||
|
||||
<Indicator label="GPS" value={gpsStr} ok={hasGps ? true : false} />
|
||||
<Indicator label="SD" value={sdStatus > 0 ? 'OK' : '--'} ok={sdStatus > 0} />
|
||||
<Indicator label="电池" value={batteryVolt ? formatBattery(batteryVolt) : '--'} />
|
||||
<Indicator label="温度" value={temperature ? formatTemperature(temperature) : '--'} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#111',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333',
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
gap: 4,
|
||||
},
|
||||
statusDot: { width: 6, height: 6, borderRadius: 3 },
|
||||
statusText: { fontSize: 11, fontWeight: '600' },
|
||||
indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||
label: { color: '#888', fontSize: 10 },
|
||||
value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
|
||||
});
|
||||
235
src/components/ParamForm.tsx
Normal file
235
src/components/ParamForm.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import {
|
||||
SEND_FREQ_TABLE,
|
||||
SAMPLE_FREQ_TABLE,
|
||||
AMP_RATIO_TABLE,
|
||||
SOURCE_MODE_TABLE,
|
||||
} from '../protocol/constants';
|
||||
|
||||
interface PickerRowProps {
|
||||
label: string;
|
||||
options: readonly { code: number; label: string }[];
|
||||
value: number;
|
||||
onChange: (code: number) => void;
|
||||
}
|
||||
|
||||
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.optScroll}>
|
||||
{options.map((o) => (
|
||||
<TouchableOpacity
|
||||
key={o.code}
|
||||
style={[styles.optChip, o.code === value && styles.optChipActive]}
|
||||
onPress={() => onChange(o.code)}
|
||||
>
|
||||
<Text style={[styles.optText, o.code === value && styles.optTextActive]}>
|
||||
{o.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberRowProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChangeText: (v: string) => void;
|
||||
suffix?: string;
|
||||
keyboardType?: 'numeric';
|
||||
}
|
||||
|
||||
function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(value)}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType={keyboardType}
|
||||
selectTextOnFocus
|
||||
placeholderTextColor="#555"
|
||||
/>
|
||||
{suffix && <Text style={styles.suffix}>{suffix}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
onAnyChange?: () => void;
|
||||
}
|
||||
|
||||
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
||||
const { config, updateConfig } = useDeviceStore();
|
||||
const patch = (partial: Partial<typeof config>) => {
|
||||
updateConfig(partial);
|
||||
onAnyChange?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Channel selector */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>通道数</Text>
|
||||
<View style={styles.channelRow}>
|
||||
{[1, 2, 3, 4, 5, 6].map((n) => (
|
||||
<TouchableOpacity
|
||||
key={n}
|
||||
style={[styles.chChip, config.channelNum === n && styles.chChipActive]}
|
||||
onPress={() => patch({ channelNum: n })}
|
||||
>
|
||||
<Text style={[styles.chText, config.channelNum === n && styles.chTextActive]}>
|
||||
{n}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<PickerRow
|
||||
label="发射频率"
|
||||
options={SEND_FREQ_TABLE}
|
||||
value={config.sendFreq}
|
||||
onChange={(v) => patch({ sendFreq: v })}
|
||||
/>
|
||||
|
||||
<PickerRow
|
||||
label="采样频率"
|
||||
options={SAMPLE_FREQ_TABLE}
|
||||
value={config.sampleFreq}
|
||||
onChange={(v) => patch({ sampleFreq: v })}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="采样点数"
|
||||
value={config.sampleDepth}
|
||||
onChangeText={(v) => patch({ sampleDepth: parseInt(v, 10) || 0 })}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="叠加次数"
|
||||
value={config.accNum}
|
||||
onChangeText={(v) => patch({ accNum: parseInt(v, 10) || 0 })}
|
||||
/>
|
||||
|
||||
<PickerRow
|
||||
label="增益"
|
||||
options={AMP_RATIO_TABLE}
|
||||
value={config.ampRatio}
|
||||
onChange={(v) => patch({ ampRatio: v })}
|
||||
/>
|
||||
|
||||
<PickerRow
|
||||
label="源模式"
|
||||
options={SOURCE_MODE_TABLE}
|
||||
value={config.sourceMode}
|
||||
onChange={(v) => patch({ sourceMode: v })}
|
||||
/>
|
||||
|
||||
{/* Reverse accumulation flags */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>反向叠加</Text>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>CH1-3</Text>
|
||||
<Switch
|
||||
value={config.negAcc123}
|
||||
onValueChange={(v) => patch({ negAcc123: v })}
|
||||
thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
|
||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
||||
/>
|
||||
<Text style={styles.switchLabel}>CH4-6</Text>
|
||||
<Switch
|
||||
value={config.negAcc456}
|
||||
onValueChange={(v) => patch({ negAcc456: v })}
|
||||
thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
|
||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<NumberRow
|
||||
label="补偿延时"
|
||||
value={config.compDisableDelay}
|
||||
onChangeText={(v) => patch({ compDisableDelay: parseInt(v, 10) || 0 })}
|
||||
suffix="×50μs"
|
||||
/>
|
||||
|
||||
{/* File prefix */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>文件前缀</Text>
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={config.filePrefix}
|
||||
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
||||
maxLength={15}
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#555"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#111' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#2a2a2a',
|
||||
gap: 10,
|
||||
},
|
||||
rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
|
||||
optScroll: { flex: 1 },
|
||||
optChip: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
marginRight: 6,
|
||||
},
|
||||
optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
||||
optText: { color: '#888', fontSize: 11 },
|
||||
optTextActive: { color: '#4a9eff', fontWeight: '600' },
|
||||
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#eee',
|
||||
fontSize: 13,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
suffix: { color: '#666', fontSize: 11, marginLeft: 6 },
|
||||
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
|
||||
chChip: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
||||
chText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
||||
chTextActive: { color: '#4a9eff' },
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
|
||||
switchLabel: { color: '#888', fontSize: 12 },
|
||||
});
|
||||
153
src/components/WaveformChart.tsx
Normal file
153
src/components/WaveformChart.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
|
||||
import { CHANNEL_COLORS } from '../protocol/constants';
|
||||
import type { WaveformData } from '../hooks/useWaveform';
|
||||
|
||||
interface Props {
|
||||
data: WaveformData;
|
||||
visibleChannels: boolean[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
|
||||
const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
|
||||
|
||||
export function WaveformChart({ data, visibleChannels, width, height }: Props) {
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = height - PADDING.top - PADDING.bottom;
|
||||
|
||||
const yMin = data.minLogUV - 0.5;
|
||||
const yMax = data.maxLogUV + 0.5;
|
||||
const yRange = yMax - yMin;
|
||||
|
||||
// Map log10(absVal) → canvas y (top=high, bottom=low)
|
||||
const toY = (uv: number): number => {
|
||||
const absV = Math.abs(uv);
|
||||
if (absV < 1e-12) return PADDING.top + plotH;
|
||||
const logV = Math.log10(absV);
|
||||
return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
|
||||
};
|
||||
|
||||
// Map sample index → canvas x
|
||||
const toX = (i: number, total: number): number =>
|
||||
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
|
||||
|
||||
// Y-axis grid lines and labels
|
||||
const yGridLines = useMemo(() => {
|
||||
return Y_TICKS.filter((v) => {
|
||||
const log = Math.log10(v);
|
||||
return log >= yMin && log <= yMax;
|
||||
});
|
||||
}, [yMin, yMax]);
|
||||
|
||||
// Channel paths
|
||||
const channelPaths = useMemo(() => {
|
||||
return data.channels.map((ch, idx) => {
|
||||
if (!visibleChannels[idx]) return null;
|
||||
const path = Skia.Path.Make();
|
||||
let moved = false;
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
const x = toX(i, ch.length);
|
||||
const y = toY(ch[i]);
|
||||
if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
|
||||
if (!moved) {
|
||||
path.moveTo(x, y);
|
||||
moved = true;
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
});
|
||||
}, [data, visibleChannels, plotW, plotH, yMin, yRange]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<Canvas style={{ width, height }}>
|
||||
{/* Background */}
|
||||
|
||||
{/* Y-axis grid lines */}
|
||||
{yGridLines.map((v) => {
|
||||
const y = toY(v);
|
||||
return (
|
||||
<Line
|
||||
key={v}
|
||||
p1={vec(PADDING.left, y)}
|
||||
p2={vec(PADDING.left + plotW, y)}
|
||||
color="#333333"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis baseline */}
|
||||
<Line
|
||||
p1={vec(PADDING.left, PADDING.top + plotH)}
|
||||
p2={vec(PADDING.left + plotW, PADDING.top + plotH)}
|
||||
color="#666666"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Channel waveform paths */}
|
||||
{channelPaths.map((path, idx) => {
|
||||
if (!path || !visibleChannels[idx]) return null;
|
||||
return (
|
||||
<Path
|
||||
key={idx}
|
||||
path={path}
|
||||
color={CHANNEL_COLORS[idx]}
|
||||
style="stroke"
|
||||
strokeWidth={1.5}
|
||||
strokeJoin="round"
|
||||
strokeCap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
|
||||
{/* Y-axis labels (React Native text overlay, positioned absolutely) */}
|
||||
{yGridLines.map((v) => {
|
||||
const y = toY(v);
|
||||
const label = v >= 1000 ? `${v / 1000}k` : v >= 1 ? `${v}` : `${v}`;
|
||||
return (
|
||||
<Text
|
||||
key={v}
|
||||
style={[styles.axisLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<Text style={[styles.xLabel, { top: height - 18, left: PADDING.left }]}>
|
||||
时间 →
|
||||
</Text>
|
||||
|
||||
{/* Y-axis unit */}
|
||||
<Text style={[styles.yUnit, { top: 0, left: 0 }]}>μV</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#1a1a1a', position: 'relative' },
|
||||
axisLabel: {
|
||||
position: 'absolute',
|
||||
color: '#aaaaaa',
|
||||
fontSize: 9,
|
||||
textAlign: 'right',
|
||||
},
|
||||
xLabel: {
|
||||
position: 'absolute',
|
||||
color: '#aaaaaa',
|
||||
fontSize: 9,
|
||||
},
|
||||
yUnit: {
|
||||
position: 'absolute',
|
||||
color: '#888888',
|
||||
fontSize: 9,
|
||||
},
|
||||
});
|
||||
100
src/hooks/useDevice.ts
Normal file
100
src/hooks/useDevice.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { tcpService } from '../services/TcpService';
|
||||
import { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop } from '../services/DeviceService';
|
||||
import { useConnectionStore } from '../stores/connectionStore';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import type { SetupConfig } from '../protocol/types';
|
||||
|
||||
export function useDevice() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const connStore = useConnectionStore();
|
||||
const devStore = useDeviceStore();
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const { host, port, setStatus, setError } = useConnectionStore.getState();
|
||||
setStatus('connecting');
|
||||
tcpService.connect(host, port, {
|
||||
onConnect: () => setStatus('connected'),
|
||||
onFrame: handleIncomingFrame,
|
||||
onError: (err) => setError(err.message),
|
||||
onClose: () => {
|
||||
if (useConnectionStore.getState().status === 'connected') {
|
||||
setStatus('reconnecting');
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
tcpService.disconnect();
|
||||
useConnectionStore.getState().setStatus('disconnected');
|
||||
useDeviceStore.getState().setDeviceStatus('idle');
|
||||
}, []);
|
||||
|
||||
const setup = useCallback(async (cfg?: SetupConfig) => {
|
||||
const config = cfg ?? useDeviceStore.getState().config;
|
||||
setBusy(true);
|
||||
try {
|
||||
const ack = await deviceSetup(config);
|
||||
if (ack.result !== 0x01) {
|
||||
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
||||
}
|
||||
return ack;
|
||||
} catch (e: any) {
|
||||
Alert.alert('配置超时', e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startContinuous = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ack = await deviceStartContinuous();
|
||||
if (ack.result !== 0x01) Alert.alert('启动失败', ack.reason);
|
||||
return ack;
|
||||
} catch (e: any) {
|
||||
Alert.alert('启动超时', e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startSingle = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ack = await deviceStartSingle();
|
||||
if (ack.result !== 0x01) Alert.alert('单次采集失败', ack.reason);
|
||||
return ack;
|
||||
} catch (e: any) {
|
||||
Alert.alert('单次采集超时', e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await deviceStop();
|
||||
} catch (e: any) {
|
||||
Alert.alert('停止超时', e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
busy,
|
||||
connected: connStore.status === 'connected',
|
||||
status: connStore.status,
|
||||
deviceStatus: devStore.deviceStatus,
|
||||
connect,
|
||||
disconnect,
|
||||
setup,
|
||||
startContinuous,
|
||||
startSingle,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
82
src/hooks/useWaveform.ts
Normal file
82
src/hooks/useWaveform.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
|
||||
const MAX_DISPLAY_POINTS = 512; // downsample to this many points for rendering
|
||||
|
||||
// Downsample an array using peak-hold (envelope) method
|
||||
function downsample(data: Float64Array, targetLen: number): Float64Array {
|
||||
if (data.length <= targetLen) return data;
|
||||
const ratio = data.length / targetLen;
|
||||
const out = new Float64Array(targetLen);
|
||||
for (let i = 0; i < targetLen; i++) {
|
||||
const start = Math.floor(i * ratio);
|
||||
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
|
||||
let maxAbs = 0;
|
||||
let maxVal = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
if (Math.abs(data[j]) > maxAbs) {
|
||||
maxAbs = Math.abs(data[j]);
|
||||
maxVal = data[j];
|
||||
}
|
||||
}
|
||||
out[i] = maxVal;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WaveformData {
|
||||
channels: Float64Array[]; // downsampled μV data per channel
|
||||
timeMs: Float64Array; // time axis in ms
|
||||
minLogUV: number; // log10 of min absolute non-zero value
|
||||
maxLogUV: number; // log10 of max absolute value
|
||||
sampleDepth: number; // original samples per channel
|
||||
}
|
||||
|
||||
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!frame) return null;
|
||||
|
||||
const { adcUV, meta } = frame;
|
||||
const sampleDepth = adcUV[0]?.length ?? 0;
|
||||
if (sampleDepth === 0) return null;
|
||||
|
||||
// Build time axis in ms using sample freq (approximation)
|
||||
// We don't have actual sample rate here, so we use index/total
|
||||
// The real time axis depends on the sendFreq and sampleFreq config
|
||||
// Use normalised 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]);
|
||||
}
|
||||
94
src/protocol/constants.ts
Normal file
94
src/protocol/constants.ts
Normal file
@ -0,0 +1,94 @@
|
||||
export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
|
||||
export const FRAME_FLAG_4BYTE = 0xfe;
|
||||
export const DEFAULT_DEVICE_IP = '192.168.4.1';
|
||||
export const DEFAULT_TCP_PORT = 4321;
|
||||
export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit
|
||||
|
||||
export const FuncCode = {
|
||||
// App → Device
|
||||
SETUP_REQ: 0x01,
|
||||
CONTINUOUS_REQ: 0x02,
|
||||
SINGLE_REQ: 0x03,
|
||||
STOP_REQ: 0x04,
|
||||
ACTIVE_REQ: 0x05,
|
||||
SPLITFRAME_REQ: 0x08,
|
||||
// Device → App
|
||||
SETUP_ACK: 0x81,
|
||||
CONTINUOUS_ACK: 0x82,
|
||||
SINGLE_ACK: 0x83,
|
||||
STOP_ACK: 0x84,
|
||||
DATA_ACK: 0x85,
|
||||
SPLITFRAME_ACK: 0x88,
|
||||
COMP_DATA_ACK: 0x95,
|
||||
} as const;
|
||||
|
||||
export const AckResult = { OK: 0x01, FAIL: 0x02 } as const;
|
||||
export const DataChannel = { USB: 0x01, P900: 0x02, WIFI: 0x03 } as const;
|
||||
|
||||
export const SplitFrameCmd = { START: 0x01, REQUEST: 0x02, STOP: 0x03, LAST: 0x82 } as const;
|
||||
|
||||
export const SEND_FREQ_TABLE = [
|
||||
{ code: 0x00, label: '0.5 Hz' },
|
||||
{ code: 0x01, label: '1 Hz' },
|
||||
{ code: 0x02, label: '2 Hz' },
|
||||
{ code: 0x03, label: '4 Hz' },
|
||||
{ code: 0x04, label: '8 Hz' },
|
||||
{ code: 0x05, label: '12.5 Hz' },
|
||||
{ code: 0x06, label: '16 Hz' },
|
||||
{ code: 0x07, label: '25 Hz' },
|
||||
{ code: 0x08, label: '32 Hz' },
|
||||
{ code: 0x09, label: '50 Hz' },
|
||||
{ code: 0x0a, label: '64 Hz' },
|
||||
// 0xFE (ZTEM) not supported
|
||||
] as const;
|
||||
|
||||
export const SAMPLE_FREQ_TABLE = [
|
||||
{ code: 0x00, label: '250 kHz' },
|
||||
{ code: 0x01, label: '125 kHz' },
|
||||
{ code: 0x02, label: '62.5 kHz' },
|
||||
{ code: 0x03, label: '31.25 kHz' },
|
||||
{ code: 0x04, label: '15.6 kHz' },
|
||||
{ code: 0x05, label: '7.8 kHz' },
|
||||
{ code: 0x06, label: '3.9 kHz' },
|
||||
{ code: 0x07, label: '1.95 kHz' },
|
||||
{ code: 0x08, label: '977 Hz' },
|
||||
{ code: 0x09, label: '488 Hz' },
|
||||
{ code: 0x0a, label: '244 Hz' },
|
||||
{ code: 0x0b, label: '122 Hz' },
|
||||
{ code: 0x0c, label: '61 Hz' },
|
||||
] as const;
|
||||
|
||||
// Gain table: ampRatio code → multiplier
|
||||
export const AMP_GAIN = [0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 1];
|
||||
|
||||
export const AMP_RATIO_TABLE = [
|
||||
{ code: 0x00, label: '1/8×', gain: 0.125 },
|
||||
{ code: 0x01, label: '1/4×', gain: 0.25 },
|
||||
{ code: 0x02, label: '1/2×', gain: 0.5 },
|
||||
{ code: 0x03, label: '1×', gain: 1 },
|
||||
{ code: 0x04, label: '2×', gain: 2 },
|
||||
{ code: 0x05, label: '4×', gain: 4 },
|
||||
{ code: 0x06, label: '8×', gain: 8 },
|
||||
{ code: 0x07, label: '16×', gain: 16 },
|
||||
{ code: 0x08, label: '32×', gain: 32 },
|
||||
{ code: 0x09, label: '64×', gain: 64 },
|
||||
{ code: 0x0a, label: '128×', gain: 128 },
|
||||
{ code: 0x0b, label: '自动', gain: 1 },
|
||||
] as const;
|
||||
|
||||
export const SOURCE_MODE_TABLE = [
|
||||
{ code: 0x00, label: '单源 A', sourceNum: 1 },
|
||||
{ code: 0x03, label: '双源 A-B', sourceNum: 2 },
|
||||
{ code: 0x04, label: '双源 A-C', sourceNum: 2 },
|
||||
{ code: 0x06, label: '三源 A-B-C', sourceNum: 3 },
|
||||
{ code: 0x07, label: '磁源 A', sourceNum: 1 },
|
||||
] as const;
|
||||
|
||||
export const CHANNEL_COLORS = [
|
||||
'#FF4444', // CH1 红
|
||||
'#44BB44', // CH2 绿
|
||||
'#4444FF', // CH3 蓝
|
||||
'#FFAA00', // CH4 橙
|
||||
'#AA44FF', // CH5 紫
|
||||
'#00CCCC', // CH6 青
|
||||
] as const;
|
||||
97
src/protocol/packet.ts
Normal file
97
src/protocol/packet.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { FRAME_FLAG_4BYTE, FuncCode, DataChannel } from './constants';
|
||||
import type { SetupConfig } from './types';
|
||||
|
||||
const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
|
||||
|
||||
// Build the 10-byte packet header
|
||||
function buildHeader(func: number, payloadLen: number): Uint8Array {
|
||||
const buf = new Uint8Array(10);
|
||||
buf.set(HEADER_MAGIC, 0);
|
||||
buf[4] = FRAME_FLAG_4BYTE;
|
||||
buf[5] = func;
|
||||
buf[6] = payloadLen & 0xff;
|
||||
buf[7] = (payloadLen >> 8) & 0xff;
|
||||
buf[8] = (payloadLen >> 16) & 0xff;
|
||||
buf[9] = (payloadLen >> 24) & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Build a command packet with a zero-filled 54-byte payload (minimum)
|
||||
function buildSimpleCommand(func: number): Uint8Array {
|
||||
const payload = new Uint8Array(54);
|
||||
const header = buildHeader(func, 54);
|
||||
const pkt = new Uint8Array(10 + 54);
|
||||
pkt.set(header, 0);
|
||||
pkt.set(payload, 10);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
// Write a little-endian uint16 into buf at offset
|
||||
function writeUint16LE(buf: Uint8Array, offset: number, value: number) {
|
||||
buf[offset] = value & 0xff;
|
||||
buf[offset + 1] = (value >> 8) & 0xff;
|
||||
}
|
||||
|
||||
// Write a little-endian uint32 into buf at offset
|
||||
function writeUint32LE(buf: Uint8Array, offset: number, value: number) {
|
||||
buf[offset] = value & 0xff;
|
||||
buf[offset + 1] = (value >> 8) & 0xff;
|
||||
buf[offset + 2] = (value >> 16) & 0xff;
|
||||
buf[offset + 3] = (value >> 24) & 0xff;
|
||||
}
|
||||
|
||||
// Write ASCII string (null-padded) into buf starting at offset
|
||||
function writeString(buf: Uint8Array, offset: number, str: string, maxLen: number) {
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
buf[offset + i] = i < str.length ? str.charCodeAt(i) & 0xff : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSetupPacket(cfg: SetupConfig): Uint8Array {
|
||||
const PAYLOAD_LEN = 64; // sizeof(setupReq_t)
|
||||
const payload = new Uint8Array(PAYLOAD_LEN);
|
||||
|
||||
payload[0] = cfg.channelNum & 0xff;
|
||||
payload[1] = cfg.sendFreq & 0xff;
|
||||
payload[2] = cfg.sampleFreq & 0xff;
|
||||
writeUint16LE(payload, 3, cfg.sampleDepth);
|
||||
|
||||
// accNum is 14-bit; bit1 = negAcc456; bit0 = negAcc123
|
||||
let accFlags = (cfg.accNum & 0x3fff) << 2;
|
||||
if (cfg.negAcc456) accFlags |= 0x02;
|
||||
if (cfg.negAcc123) accFlags |= 0x01;
|
||||
writeUint16LE(payload, 5, accFlags);
|
||||
|
||||
payload[7] = cfg.ampRatio & 0xff;
|
||||
payload[8] = cfg.dataChannel & 0xff;
|
||||
payload[9] = cfg.compRes & 0xff;
|
||||
payload[10] = cfg.secondSampleFreq & 0xff;
|
||||
writeUint16LE(payload, 11, cfg.compDisableDelay);
|
||||
payload[13] = cfg.sourceMode & 0xff;
|
||||
writeUint16LE(payload, 14, cfg.batteryVoltageMin);
|
||||
// bytes 16-37: reserved (zeros)
|
||||
writeString(payload, 38, cfg.filePrefix, 16);
|
||||
|
||||
const header = buildHeader(FuncCode.SETUP_REQ, PAYLOAD_LEN);
|
||||
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
|
||||
pkt.set(header, 0);
|
||||
pkt.set(payload, 10);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
export const buildContinuousReq = () => buildSimpleCommand(FuncCode.CONTINUOUS_REQ);
|
||||
export const buildSingleReq = () => buildSimpleCommand(FuncCode.SINGLE_REQ);
|
||||
export const buildStopReq = () => buildSimpleCommand(FuncCode.STOP_REQ);
|
||||
export const buildActiveReq = () => buildSimpleCommand(FuncCode.ACTIVE_REQ);
|
||||
|
||||
export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array {
|
||||
const PAYLOAD_LEN = 54;
|
||||
const payload = new Uint8Array(PAYLOAD_LEN);
|
||||
payload[0] = subCmd & 0xff;
|
||||
writeUint16LE(payload, 1, frameNo);
|
||||
const header = buildHeader(FuncCode.SPLITFRAME_REQ, PAYLOAD_LEN);
|
||||
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
|
||||
pkt.set(header, 0);
|
||||
pkt.set(payload, 10);
|
||||
return pkt;
|
||||
}
|
||||
178
src/protocol/parser.ts
Normal file
178
src/protocol/parser.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { MAX_PAYLOAD_SIZE } from './constants';
|
||||
import type { RawFrame, MeasurementMeta, MeasurementFrame, AckPacket } from './types';
|
||||
import { FuncCode, AMP_GAIN } from './constants';
|
||||
|
||||
// Streaming TCP frame parser.
|
||||
// Handles packet fragmentation (TCP splits large packets across multiple reads).
|
||||
export class TemFrameParser {
|
||||
private buf: Uint8Array;
|
||||
private len = 0;
|
||||
private readonly capacity: number;
|
||||
|
||||
constructor(capacity = 2 * 1024 * 1024) {
|
||||
this.capacity = capacity;
|
||||
this.buf = new Uint8Array(capacity);
|
||||
}
|
||||
|
||||
feed(chunk: Uint8Array | ArrayBuffer | { buffer: ArrayBuffer; byteOffset: number; byteLength: number }): RawFrame[] {
|
||||
let incoming: Uint8Array;
|
||||
if (chunk instanceof Uint8Array) {
|
||||
incoming = chunk;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
incoming = new Uint8Array(chunk);
|
||||
} else {
|
||||
incoming = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
|
||||
if (this.len + incoming.length > this.capacity) {
|
||||
// Buffer overflow — reset and resync
|
||||
this.len = 0;
|
||||
}
|
||||
this.buf.set(incoming, this.len);
|
||||
this.len += incoming.length;
|
||||
|
||||
const results: RawFrame[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < this.len) {
|
||||
const magic = this.findMagic(offset);
|
||||
if (magic === -1) {
|
||||
// No header found — keep last 3 bytes in case they start a header
|
||||
const keep = Math.min(3, this.len - offset);
|
||||
this.buf.copyWithin(0, this.len - keep);
|
||||
this.len = keep;
|
||||
return results;
|
||||
}
|
||||
offset = magic;
|
||||
|
||||
// Wait until we have the full 10-byte header
|
||||
if (this.len - offset < 10) break;
|
||||
|
||||
const flag = this.buf[offset + 4];
|
||||
const func = this.buf[offset + 5];
|
||||
const payloadLen = this.readUint32LE(offset + 6);
|
||||
|
||||
if (payloadLen > MAX_PAYLOAD_SIZE) {
|
||||
// Bogus length — skip this magic and search again
|
||||
offset += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalLen = 10 + payloadLen;
|
||||
if (this.len - offset < totalLen) break; // wait for more data
|
||||
|
||||
const payload = new Uint8Array(totalLen - 10);
|
||||
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
|
||||
results.push({ flag, func, payload });
|
||||
offset += totalLen;
|
||||
}
|
||||
|
||||
// Compact — move remaining bytes to front
|
||||
if (offset > 0 && offset <= this.len) {
|
||||
this.buf.copyWithin(0, offset, this.len);
|
||||
this.len -= offset;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.len = 0;
|
||||
}
|
||||
|
||||
private findMagic(start: number): number {
|
||||
const b = this.buf;
|
||||
const end = this.len - 3;
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (b[i] === 0x68 && b[i + 1] === 0x68 && b[i + 2] === 0xff && b[i + 3] === 0xff) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private readUint32LE(offset: number): number {
|
||||
const b = this.buf;
|
||||
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the metadata header of a 0x85 or 0x95 DATA_ACK payload
|
||||
export function parseMetadata(payload: Uint8Array): MeasurementMeta | null {
|
||||
if (payload.length < 54) return null;
|
||||
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||
let o = 0;
|
||||
const devId = view.getUint8(o++);
|
||||
const utc = view.getUint32(o, true); o += 4;
|
||||
const longitude = view.getFloat64(o, true); o += 8;
|
||||
const latitude = view.getFloat64(o, true); o += 8;
|
||||
const altitude = view.getFloat32(o, true); o += 4;
|
||||
const height = view.getFloat32(o, true); o += 4;
|
||||
const sdGps = view.getUint8(o++);
|
||||
const ampRatio = view.getUint8(o++);
|
||||
const roll = view.getFloat32(o, true); o += 4;
|
||||
const pitch = view.getFloat32(o, true); o += 4;
|
||||
const yaw = view.getFloat32(o, true); o += 4;
|
||||
const channelNum = view.getUint8(o++);
|
||||
const current = view.getUint32(o, true); o += 4;
|
||||
const temperature = view.getUint16(o, true); o += 2;
|
||||
const batteryVolt = view.getUint16(o, true); o += 2;
|
||||
const sourceMode = view.getUint8(o++);
|
||||
return {
|
||||
devId, utc, longitude, latitude, altitude, height,
|
||||
sdStatus: sdGps & 0x0f,
|
||||
gpsStatus: (sdGps >> 4) & 0x0f,
|
||||
ampRatio, roll, pitch, yaw, channelNum,
|
||||
current, temperature, batteryVolt, sourceMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse ADC sample data from a 0x85/0x95 payload into per-channel arrays
|
||||
export function parseAdcData(
|
||||
payload: Uint8Array,
|
||||
channelNum: number,
|
||||
accNum: number,
|
||||
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
|
||||
const META_SIZE = 54;
|
||||
const sampleDataLen = payload.length - META_SIZE;
|
||||
const totalSamples = sampleDataLen / 4; // int32 per sample
|
||||
const samplesPerChannel = Math.floor(totalSamples / channelNum);
|
||||
|
||||
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
|
||||
const ampRatio = payload[9]; // ampRatio in metadata at offset 9 from payload start...
|
||||
// actually re-derive from parseMetadata
|
||||
const gain = AMP_GAIN[payload[9 + 1]] ?? 1; // byte 10 of payload (after devId+utc+lon+lat+alt+height+sdGps = 1+4+8+8+4+4+1 = 30)
|
||||
// Correct: ampRatio is at offset 31 in payload (1+4+8+8+4+4+1 = 30, then ampRatio at 30)
|
||||
const correctGain = AMP_GAIN[payload[30]] ?? 1;
|
||||
|
||||
const adcRaw: Int32Array[] = [];
|
||||
const adcUV: Float64Array[] = [];
|
||||
|
||||
for (let ch = 0; ch < channelNum; ch++) {
|
||||
const raw = new Int32Array(samplesPerChannel);
|
||||
const uv = new Float64Array(samplesPerChannel);
|
||||
for (let i = 0; i < samplesPerChannel; i++) {
|
||||
const idx = (ch * samplesPerChannel + i) * 4;
|
||||
const val = view.getInt32(idx, true);
|
||||
raw[i] = val;
|
||||
uv[i] = val / (accNum || 1) / correctGain;
|
||||
}
|
||||
adcRaw.push(raw);
|
||||
adcUV.push(uv);
|
||||
}
|
||||
|
||||
return { adcRaw, adcUV };
|
||||
}
|
||||
|
||||
// Parse a generic ACK packet (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 };
|
||||
}
|
||||
83
src/protocol/types.ts
Normal file
83
src/protocol/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// Raw parsed frame from the TCP stream
|
||||
export interface RawFrame {
|
||||
flag: number;
|
||||
func: number;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
// Setup configuration sent to device (maps to setupReq_t)
|
||||
export interface SetupConfig {
|
||||
channelNum: number; // 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';
|
||||
167
src/services/DeviceService.ts
Normal file
167
src/services/DeviceService.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { tcpService } from './TcpService';
|
||||
import {
|
||||
buildSetupPacket,
|
||||
buildContinuousReq,
|
||||
buildSingleReq,
|
||||
buildStopReq,
|
||||
buildSplitFrameReq,
|
||||
} from '../protocol/packet';
|
||||
import {
|
||||
parseMetadata,
|
||||
parseAdcData,
|
||||
parseAck,
|
||||
} from '../protocol/parser';
|
||||
import { FuncCode, SplitFrameCmd, AckResult } from '../protocol/constants';
|
||||
import type { RawFrame, SetupConfig, MeasurementFrame, AckPacket } from '../protocol/types';
|
||||
import { useConnectionStore } from '../stores/connectionStore';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
|
||||
let frameCounter = 0;
|
||||
|
||||
// Pending ACK promise resolver keyed by func code
|
||||
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
||||
|
||||
// Split frame accumulation state
|
||||
let splitChunks: Uint8Array[] = [];
|
||||
let splitActive = false;
|
||||
let splitFrameNo = 0;
|
||||
|
||||
export function handleIncomingFrame(frame: RawFrame) {
|
||||
const { func, payload } = frame;
|
||||
const devStore = useDeviceStore.getState();
|
||||
const dataStore = useDataStore.getState();
|
||||
|
||||
switch (func) {
|
||||
// ── ACK responses ──────────────────────────────────────────────
|
||||
case FuncCode.SETUP_ACK:
|
||||
case FuncCode.CONTINUOUS_ACK:
|
||||
case FuncCode.SINGLE_ACK:
|
||||
case FuncCode.STOP_ACK: {
|
||||
const ack = parseAck(payload);
|
||||
const resolver = pendingAcks.get(func);
|
||||
if (resolver) {
|
||||
pendingAcks.delete(func);
|
||||
resolver(ack);
|
||||
}
|
||||
if (func === FuncCode.CONTINUOUS_ACK && ack.result === AckResult.OK) {
|
||||
devStore.setDeviceStatus('running');
|
||||
}
|
||||
if (func === FuncCode.SINGLE_ACK && ack.result === AckResult.OK) {
|
||||
devStore.setDeviceStatus('single');
|
||||
}
|
||||
if (func === FuncCode.STOP_ACK) {
|
||||
devStore.setDeviceStatus('idle');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Measurement data (single or multi-source) ──────────────────
|
||||
case FuncCode.DATA_ACK:
|
||||
case FuncCode.COMP_DATA_ACK: {
|
||||
processMeasurementPayload(payload, devStore, dataStore);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Split frame response ───────────────────────────────────────
|
||||
case FuncCode.SPLITFRAME_ACK: {
|
||||
const subCmd = payload[0];
|
||||
// Data starts at byte 3 (subCmd + frameNo uint16)
|
||||
const chunk = payload.slice(3);
|
||||
splitChunks.push(chunk);
|
||||
|
||||
if (subCmd === SplitFrameCmd.LAST) {
|
||||
// Reassemble and process
|
||||
const total = splitChunks.reduce((s, c) => s + c.length, 0);
|
||||
const assembled = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of splitChunks) {
|
||||
assembled.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
splitChunks = [];
|
||||
splitActive = false;
|
||||
processMeasurementPayload(assembled, devStore, dataStore);
|
||||
} else {
|
||||
// Request next frame
|
||||
splitFrameNo++;
|
||||
tcpService.send(buildSplitFrameReq(SplitFrameCmd.REQUEST, splitFrameNo));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processMeasurementPayload(
|
||||
payload: Uint8Array,
|
||||
devStore: ReturnType<typeof useDeviceStore.getState>,
|
||||
dataStore: ReturnType<typeof useDataStore.getState>,
|
||||
) {
|
||||
const meta = parseMetadata(payload);
|
||||
if (!meta) return;
|
||||
|
||||
const cfg = devStore.config;
|
||||
const accNum = cfg.accNum || 1;
|
||||
const { adcRaw, adcUV } = parseAdcData(payload, meta.channelNum, accNum);
|
||||
|
||||
const measurementFrame: MeasurementFrame = {
|
||||
meta,
|
||||
adcRaw,
|
||||
adcUV,
|
||||
accNum,
|
||||
gain: cfg.ampRatio,
|
||||
timestamp: Date.now(),
|
||||
frameId: frameCounter++,
|
||||
};
|
||||
|
||||
dataStore.addFrame(measurementFrame);
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingAcks.delete(ackFunc);
|
||||
reject(new Error('ACK timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
pendingAcks.set(ackFunc, (ack) => {
|
||||
clearTimeout(timer);
|
||||
resolve(ack);
|
||||
});
|
||||
|
||||
const sent = tcpService.send(pkt);
|
||||
if (!sent) {
|
||||
clearTimeout(timer);
|
||||
pendingAcks.delete(ackFunc);
|
||||
reject(new Error('TCP not connected'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deviceSetup(config: SetupConfig): Promise<AckPacket> {
|
||||
useDeviceStore.getState().setConfig(config);
|
||||
return sendAndWaitAck(buildSetupPacket(config), FuncCode.SETUP_ACK);
|
||||
}
|
||||
|
||||
export async function deviceStartContinuous(): Promise<AckPacket> {
|
||||
return sendAndWaitAck(buildContinuousReq(), FuncCode.CONTINUOUS_ACK);
|
||||
}
|
||||
|
||||
export async function deviceStartSingle(): Promise<AckPacket> {
|
||||
return sendAndWaitAck(buildSingleReq(), FuncCode.SINGLE_ACK);
|
||||
}
|
||||
|
||||
export async function deviceStop(): Promise<AckPacket> {
|
||||
return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK);
|
||||
}
|
||||
|
||||
// Initiate split frame transfer to request large data
|
||||
export function deviceStartSplitFrame() {
|
||||
if (splitActive) return;
|
||||
splitActive = true;
|
||||
splitFrameNo = 0;
|
||||
splitChunks = [];
|
||||
tcpService.send(buildSplitFrameReq(SplitFrameCmd.START, 0));
|
||||
}
|
||||
108
src/services/TcpService.ts
Normal file
108
src/services/TcpService.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import TcpSocket from 'react-native-tcp-socket';
|
||||
import { TemFrameParser } from '../protocol/parser';
|
||||
import type { RawFrame } from '../protocol/types';
|
||||
|
||||
export type TcpCallbacks = {
|
||||
onConnect: () => void;
|
||||
onFrame: (frame: RawFrame) => void;
|
||||
onError: (err: Error) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
class TcpService {
|
||||
private socket: ReturnType<typeof TcpSocket.createConnection> | null = null;
|
||||
private parser = new TemFrameParser();
|
||||
private callbacks: TcpCallbacks | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectEnabled = false;
|
||||
private host = '';
|
||||
private port = 0;
|
||||
|
||||
connect(host: string, port: number, callbacks: TcpCallbacks) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.callbacks = callbacks;
|
||||
this.reconnectEnabled = true;
|
||||
this.parser.reset();
|
||||
this.createSocket();
|
||||
}
|
||||
|
||||
private createSocket() {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
const sock = TcpSocket.createConnection(
|
||||
{ host: this.host, port: this.port, tls: false },
|
||||
() => {
|
||||
this.connected = true;
|
||||
clearTimeout(this.reconnectTimer!);
|
||||
this.callbacks?.onConnect();
|
||||
},
|
||||
);
|
||||
|
||||
sock.on('data', (data: any) => {
|
||||
const arr: Uint8Array =
|
||||
data instanceof Uint8Array
|
||||
? data
|
||||
: data instanceof ArrayBuffer
|
||||
? new Uint8Array(data)
|
||||
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const frames = this.parser.feed(arr);
|
||||
frames.forEach((f) => this.callbacks?.onFrame(f));
|
||||
});
|
||||
|
||||
sock.on('error', (err: Error) => {
|
||||
this.connected = false;
|
||||
this.callbacks?.onError(err);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
sock.on('close', () => {
|
||||
this.connected = false;
|
||||
this.callbacks?.onClose();
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
this.socket = sock;
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (!this.reconnectEnabled) return;
|
||||
clearTimeout(this.reconnectTimer!);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (this.reconnectEnabled) {
|
||||
this.parser.reset();
|
||||
this.createSocket();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
send(data: Uint8Array): boolean {
|
||||
if (!this.connected || !this.socket) return false;
|
||||
try {
|
||||
this.socket.write(data as unknown as string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.reconnectEnabled = false;
|
||||
clearTimeout(this.reconnectTimer!);
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
this.parser.reset();
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance shared across the app
|
||||
export const tcpService = new TcpService();
|
||||
25
src/stores/connectionStore.ts
Normal file
25
src/stores/connectionStore.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
|
||||
import type { ConnectionStatus } from '../protocol/types';
|
||||
|
||||
interface ConnectionState {
|
||||
status: ConnectionStatus;
|
||||
host: string;
|
||||
port: number;
|
||||
lastError: string;
|
||||
setStatus: (s: ConnectionStatus) => void;
|
||||
setHost: (h: string) => void;
|
||||
setPort: (p: number) => void;
|
||||
setError: (e: string) => void;
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
status: 'disconnected',
|
||||
host: DEFAULT_DEVICE_IP,
|
||||
port: DEFAULT_TCP_PORT,
|
||||
lastError: '',
|
||||
setStatus: (status) => set({ status }),
|
||||
setHost: (host) => set({ host }),
|
||||
setPort: (port) => set({ port }),
|
||||
setError: (lastError) => set({ lastError, status: 'error' }),
|
||||
}));
|
||||
35
src/stores/dataStore.ts
Normal file
35
src/stores/dataStore.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
|
||||
const MAX_HISTORY = 500; // keep last 500 frames in memory
|
||||
|
||||
interface DataState {
|
||||
currentFrame: MeasurementFrame | null;
|
||||
history: MeasurementFrame[]; // newest first
|
||||
sessionId: string;
|
||||
addFrame: (frame: MeasurementFrame) => void;
|
||||
clearHistory: () => void;
|
||||
newSession: () => void;
|
||||
}
|
||||
|
||||
export const useDataStore = create<DataState>((set) => ({
|
||||
currentFrame: null,
|
||||
history: [],
|
||||
sessionId: generateSessionId(),
|
||||
addFrame: (frame) =>
|
||||
set((s) => ({
|
||||
currentFrame: frame,
|
||||
history: [frame, ...s.history].slice(0, MAX_HISTORY),
|
||||
})),
|
||||
clearHistory: () => set({ history: [], currentFrame: null }),
|
||||
newSession: () => set({ sessionId: generateSessionId(), history: [], currentFrame: null }),
|
||||
}));
|
||||
|
||||
function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
||||
return (
|
||||
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||
`_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
);
|
||||
}
|
||||
59
src/stores/deviceStore.ts
Normal file
59
src/stores/deviceStore.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SetupConfig, DeviceStatus } from '../protocol/types';
|
||||
import { DataChannel } from '../protocol/constants';
|
||||
|
||||
const DEFAULT_CONFIG: SetupConfig = {
|
||||
channelNum: 3,
|
||||
sendFreq: 0x03, // 4 Hz
|
||||
sampleFreq: 0x03, // 31.25 kHz
|
||||
sampleDepth: 1024,
|
||||
accNum: 50,
|
||||
negAcc456: false,
|
||||
negAcc123: false,
|
||||
ampRatio: 0x03, // 1×
|
||||
dataChannel: DataChannel.WIFI,
|
||||
compRes: 0,
|
||||
secondSampleFreq: 0x03,
|
||||
compDisableDelay: 300, // 300 × 50μs = 15ms
|
||||
sourceMode: 0x00, // single source A
|
||||
batteryVoltageMin: 0,
|
||||
filePrefix: 'TEM',
|
||||
};
|
||||
|
||||
interface DeviceState {
|
||||
config: SetupConfig;
|
||||
deviceStatus: DeviceStatus;
|
||||
batteryVolt: number; // raw ADC
|
||||
temperature: number; // raw ADC
|
||||
gpsStatus: number;
|
||||
sdStatus: number;
|
||||
frameId: number; // last received frame ID
|
||||
accCount: number; // stacking progress
|
||||
setConfig: (cfg: SetupConfig) => void;
|
||||
updateConfig: (partial: Partial<SetupConfig>) => void;
|
||||
setDeviceStatus: (s: DeviceStatus) => void;
|
||||
updateTelemetry: (data: {
|
||||
batteryVolt?: number;
|
||||
temperature?: number;
|
||||
gpsStatus?: number;
|
||||
sdStatus?: number;
|
||||
frameId?: number;
|
||||
accCount?: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const useDeviceStore = create<DeviceState>((set) => ({
|
||||
config: DEFAULT_CONFIG,
|
||||
deviceStatus: 'idle',
|
||||
batteryVolt: 0,
|
||||
temperature: 0,
|
||||
gpsStatus: 0,
|
||||
sdStatus: 0,
|
||||
frameId: 0,
|
||||
accCount: 0,
|
||||
setConfig: (config) => set({ config }),
|
||||
updateConfig: (partial) =>
|
||||
set((s) => ({ config: { ...s.config, ...partial } })),
|
||||
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
|
||||
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
|
||||
}));
|
||||
105
src/utils/export.ts
Normal file
105
src/utils/export.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// Use legacy API for documentDirectory compatibility
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
import { formatUtc, formatCoord } from './format';
|
||||
|
||||
const DATA_DIR = (FileSystem.documentDirectory ?? '') + 'tem_data/';
|
||||
|
||||
async function ensureDir() {
|
||||
const info = await FileSystem.getInfoAsync(DATA_DIR);
|
||||
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
// Build CSV content for a list of frames
|
||||
function framesToCsv(frames: MeasurementFrame[]): string {
|
||||
if (frames.length === 0) return '';
|
||||
|
||||
const channelNum = frames[0].meta.channelNum;
|
||||
const chHeaders = Array.from({ length: channelNum }, (_, i) => `CH${i + 1}_peak_uV`).join(',');
|
||||
const header = `FrameID,UTC,Longitude,Latitude,Altitude,GPS,SD,AmpRatio,Current,Temp,Battery,Roll,Pitch,Yaw,${chHeaders}`;
|
||||
|
||||
const rows = frames.map((f) => {
|
||||
const m = f.meta;
|
||||
const peaks = f.adcUV.map((ch) => {
|
||||
const maxAbs = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||
return maxAbs.toFixed(4);
|
||||
});
|
||||
return [
|
||||
f.frameId,
|
||||
formatUtc(m.utc),
|
||||
m.longitude.toFixed(7),
|
||||
m.latitude.toFixed(7),
|
||||
m.altitude.toFixed(2),
|
||||
m.gpsStatus,
|
||||
m.sdStatus,
|
||||
m.ampRatio,
|
||||
m.current,
|
||||
m.temperature,
|
||||
m.batteryVolt,
|
||||
m.roll.toFixed(2),
|
||||
m.pitch.toFixed(2),
|
||||
m.yaw.toFixed(2),
|
||||
...peaks,
|
||||
].join(',');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export async function exportCsv(frames: MeasurementFrame[], sessionId: string): Promise<string> {
|
||||
await ensureDir();
|
||||
const filename = `TEM_${sessionId}.csv`;
|
||||
const path = DATA_DIR + filename;
|
||||
const csv = framesToCsv(frames);
|
||||
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
|
||||
return path;
|
||||
}
|
||||
|
||||
// Save a single frame's raw ADC data as binary file
|
||||
export async function saveFrameBin(frame: MeasurementFrame, sessionId: string): Promise<string> {
|
||||
await ensureDir();
|
||||
const filename = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
|
||||
const path = DATA_DIR + filename;
|
||||
|
||||
const channelNum = frame.adcRaw.length;
|
||||
const samplesPerCh = frame.adcRaw[0]?.length ?? 0;
|
||||
// Header: magic(4) + channelNum(4) + samplesPerCh(4) = 12 bytes
|
||||
const totalBytes = 12 + channelNum * samplesPerCh * 4;
|
||||
const buf = new ArrayBuffer(totalBytes);
|
||||
const view = new DataView(buf);
|
||||
|
||||
// Magic: "TEM\x01"
|
||||
view.setUint8(0, 0x54); view.setUint8(1, 0x45); view.setUint8(2, 0x4d); view.setUint8(3, 0x01);
|
||||
view.setUint32(4, channelNum, true);
|
||||
view.setUint32(8, samplesPerCh, true);
|
||||
|
||||
let offset = 12;
|
||||
for (const ch of frame.adcRaw) {
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
view.setInt32(offset, ch[i], true);
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to base64 string for expo-file-system
|
||||
const uint8 = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
|
||||
const b64 = btoa(binary);
|
||||
await FileSystem.writeAsStringAsync(path, b64, { encoding: FileSystem.EncodingType.Base64 });
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function shareFile(filePath: string) {
|
||||
const canShare = await Sharing.isAvailableAsync();
|
||||
if (canShare) {
|
||||
await Sharing.shareAsync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessionFiles(sessionId: string): Promise<string[]> {
|
||||
await ensureDir();
|
||||
const all = await FileSystem.readDirectoryAsync(DATA_DIR);
|
||||
return all.filter((f) => f.startsWith(sessionId)).map((f) => DATA_DIR + f);
|
||||
}
|
||||
51
src/utils/format.ts
Normal file
51
src/utils/format.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
|
||||
|
||||
export function formatUV(uv: number): string {
|
||||
const abs = Math.abs(uv);
|
||||
if (abs >= 1e6) return `${(uv / 1e6).toFixed(2)} V`;
|
||||
if (abs >= 1e3) return `${(uv / 1e3).toFixed(2)} mV`;
|
||||
if (abs >= 1) return `${uv.toFixed(2)} μV`;
|
||||
if (abs >= 1e-3) return `${(uv * 1e3).toFixed(2)} nV`;
|
||||
return `${uv.toExponential(2)} μV`;
|
||||
}
|
||||
|
||||
export function formatCoord(deg: number, isLon: boolean): string {
|
||||
if (!isFinite(deg)) return '--';
|
||||
const dir = isLon ? (deg >= 0 ? 'E' : 'W') : deg >= 0 ? 'N' : 'S';
|
||||
return `${Math.abs(deg).toFixed(6)}°${dir}`;
|
||||
}
|
||||
|
||||
export function formatUtc(utcSec: number): string {
|
||||
if (!utcSec) return '--';
|
||||
const d = new Date(utcSec * 1000);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function formatBattery(raw: number): string {
|
||||
// raw ADC → voltage conversion depends on hardware; placeholder
|
||||
const v = (raw / 4096) * 3.3 * 10; // rough estimate
|
||||
return `${v.toFixed(1)} V`;
|
||||
}
|
||||
|
||||
export function formatTemperature(raw: number): string {
|
||||
// placeholder conversion
|
||||
const c = raw / 10;
|
||||
return `${c.toFixed(1)} °C`;
|
||||
}
|
||||
|
||||
export function sendFreqLabel(code: number): string {
|
||||
return SEND_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
|
||||
}
|
||||
|
||||
export function sampleFreqLabel(code: number): string {
|
||||
return SAMPLE_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
|
||||
}
|
||||
|
||||
export function ampRatioLabel(code: number): string {
|
||||
return AMP_RATIO_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
|
||||
}
|
||||
|
||||
export function sourceModeLabel(code: number): string {
|
||||
return SOURCE_MODE_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user