基本功能完成

This commit is contained in:
2026-06-20 21:43:09 +08:00
parent 84590d5477
commit a89d23b43c
45 changed files with 3508 additions and 1261 deletions
+6 -4
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { CHANNEL_COLORS } from '../protocol/constants';
import { useTheme } from '../design/tokens';
interface Props {
maxChannels: number;
@@ -9,6 +10,8 @@ interface Props {
}
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
const theme = useTheme();
return (
<View style={S.row}>
{Array.from({ length: maxChannels }, (_, i) => {
@@ -19,14 +22,14 @@ export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
key={i}
style={[
S.chip,
{ borderColor: active ? color + '88' : '#1e1e30' },
{ backgroundColor: theme.bg.base, borderColor: active ? color + '88' : theme.bg.border },
active && { backgroundColor: color + '14' },
]}
onPress={() => onToggle(i)}
activeOpacity={0.7}
>
<View style={[S.dot, { backgroundColor: active ? color : '#2a2a3a' }]} />
<Text style={[S.label, { color: active ? color : '#3a3a5a' }]}>CH{i + 1}</Text>
<View style={[S.dot, { backgroundColor: active ? color : theme.text.ghost }]} />
<Text style={[S.label, { color: active ? color : theme.text.muted }]}>CH{i + 1}</Text>
</TouchableOpacity>
);
})}
@@ -44,7 +47,6 @@ const S = StyleSheet.create({
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 5,
backgroundColor: '#0c0c18',
},
dot: { width: 6, height: 6, borderRadius: 3 },
label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
+16 -18
View File
@@ -3,18 +3,20 @@ import { View, Text, StyleSheet, Platform } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
import { formatBattery, formatTemperature } from '../utils/format';
import { useTheme, type ThemeColors } from '../design/tokens';
function Chip({ label, value, color }: { label: string; value: string; color?: string }) {
function Chip({ label, value, color, theme }: { label: string; value: string; color?: string; theme: ThemeColors }) {
return (
<View style={[S.chip, color ? { borderColor: color + '44' } : null]}>
<View style={[S.chip, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }, color ? { borderColor: color + '44' } : null]}>
{color && <View style={[S.chipDot, { backgroundColor: color }]} />}
<Text style={S.chipLabel}>{label}</Text>
<Text style={[S.chipValue, color ? { color } : null]}>{value}</Text>
<Text style={[S.chipLabel, { color: theme.text.muted }]}>{label}</Text>
<Text style={[S.chipValue, { color: color ?? theme.text.secondary }]}>{value}</Text>
</View>
);
}
export function DeviceStatusBar() {
const theme = useTheme();
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
const frame = useDataStore((s) => s.currentFrame);
const meta = frame?.meta;
@@ -26,22 +28,22 @@ export function DeviceStatusBar() {
const running = deviceStatus === 'running';
const single = deviceStatus === 'single';
const statusColor = running ? '#3ddc84' : single ? '#ffb84d' : '#3a3a5a';
const statusColor = running ? theme.green.fg : single ? theme.amber.fg : theme.text.muted;
const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止';
return (
<View style={S.bar}>
<View style={[S.bar, { backgroundColor: theme.bg.surface, borderBottomColor: theme.bg.border }]}>
<View style={[S.statusPill, { borderColor: statusColor + '55', backgroundColor: statusColor + '18' }]}>
<Text style={[S.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
<Chip label="GPS" value={coordStr} color={hasGps ? '#3ddc84' : '#3a3a5a'} />
<Chip label="SD" value={sdStatus > 0 ? 'OK' : '--'} color={sdStatus > 0 ? '#4a9eff' : '#3a3a5a'} />
{!!batteryVolt && <Chip label="电池" value={formatBattery(batteryVolt)} />}
{!!temperature && <Chip label="温度" value={formatTemperature(temperature)} />}
<Chip label="GPS" value={coordStr} color={hasGps ? theme.green.fg : theme.text.muted} theme={theme} />
<Chip label="SD" value={sdStatus > 0 ? 'OK' : '--'} color={sdStatus > 0 ? theme.blue.fg : theme.text.muted} theme={theme} />
{!!batteryVolt && <Chip label="电池" value={formatBattery(batteryVolt)} theme={theme} />}
{!!temperature && <Chip label="温度" value={formatTemperature(temperature)} theme={theme} />}
{frame && (
<View style={S.frameCount}>
<Text style={S.frameCountText}>#{frame.frameId}</Text>
<Text style={[S.frameCountText, { color: theme.text.ghost }]}>#{frame.frameId}</Text>
</View>
)}
</View>
@@ -51,14 +53,12 @@ export function DeviceStatusBar() {
const S = StyleSheet.create({
bar: {
flexDirection: 'row',
backgroundColor: '#0c0c18',
paddingHorizontal: 12,
paddingVertical: 8,
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
borderBottomWidth: 1,
borderBottomColor: '#1a1a2a',
},
statusPill: {
borderRadius: 6,
@@ -71,16 +71,14 @@ const S = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: '#111120',
borderRadius: 6,
borderWidth: 1,
borderColor: '#22223a',
paddingHorizontal: 7,
paddingVertical: 3,
},
chipDot: { width: 5, height: 5, borderRadius: 3 },
chipLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
chipValue: { color: '#6a6a8a', fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
chipLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
chipValue: { fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
frameCount: { marginLeft: 'auto' },
frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
frameCountText: { fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { router } from 'expo-router';
import { useTheme } from '../design/tokens';
export function NoProjectGate() {
const theme = useTheme();
return (
<View style={S.root}>
<Text style={[S.icon, { color: theme.bg.border }]}>◫</Text>
<Text style={[S.title, { color: theme.text.muted }]}>请先创建工程</Text>
<Text style={[S.sub, { color: theme.text.ghost }]}>
在「工程」页面创建工程后即可开始采集
</Text>
<TouchableOpacity
style={[S.btn, { backgroundColor: theme.blue.bg, borderColor: theme.blue.fg }]}
onPress={() => router.push('/(tabs)/projects')}
activeOpacity={0.8}
>
<Text style={[S.btnTxt, { color: theme.blue.fg }]}>前往工程页</Text>
</TouchableOpacity>
</View>
);
}
const S = StyleSheet.create({
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
icon: { fontSize: 48 },
title: { fontSize: 17, fontWeight: '700' },
sub: { fontSize: 12, textAlign: 'center', paddingHorizontal: 40 },
btn: { marginTop: 8, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 32, borderWidth: 1 },
btnTxt: { fontSize: 14, fontWeight: '700', letterSpacing: 2 },
});
+54 -50
View File
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch, Platform } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
import { useTheme } from '../design/tokens';
interface PickerRowProps {
label: string;
@@ -10,21 +11,33 @@ interface PickerRowProps {
onChange: (code: number) => void;
}
const CHIP_W = 72;
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
const theme = useTheme();
const scrollRef = useRef<ScrollView>(null);
const activeIdx = options.findIndex((o) => o.code === value);
useEffect(() => {
if (activeIdx > 0 && scrollRef.current) {
scrollRef.current.scrollTo({ x: Math.max(0, activeIdx * CHIP_W - CHIP_W), animated: false });
}
}, [activeIdx]);
return (
<View style={S.row}>
<Text style={S.rowLabel}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={S.optScroll}>
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
<Text style={[S.rowLabel, { color: theme.text.muted }]}>{label}</Text>
<ScrollView ref={scrollRef} horizontal showsHorizontalScrollIndicator={false} style={S.optScroll}>
{options.map((o) => {
const active = o.code === value;
return (
<TouchableOpacity
key={o.code}
style={[S.optChip, active && S.optChipActive]}
style={[S.optChip, { borderColor: theme.bg.border }, active && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
onPress={() => onChange(o.code)}
activeOpacity={0.7}
>
<Text style={[S.optText, active && S.optTextActive]}>{o.label}</Text>
<Text style={[S.optText, { color: theme.text.muted }, active && { color: theme.blue.fg, fontWeight: '700' }]}>{o.label}</Text>
</TouchableOpacity>
);
})}
@@ -44,6 +57,7 @@ interface NumberRowProps {
// Keeps a local draft string while typing; commits a clamped integer on blur.
function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) {
const theme = useTheme();
const [draft, setDraft] = useState(String(value));
// Sync when the store value changes externally (e.g. presets).
@@ -62,31 +76,32 @@ function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps)
})();
return (
<View style={S.row}>
<Text style={S.rowLabel}>{label}</Text>
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
<Text style={[S.rowLabel, { color: theme.text.muted }]}>{label}</Text>
<View style={S.inputWrap}>
<TextInput
style={[S.input, invalid && S.inputInvalid]}
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }, invalid && { borderBottomColor: theme.red.fg }]}
value={draft}
onChangeText={setDraft}
onBlur={commit}
keyboardType="numeric"
selectTextOnFocus
placeholderTextColor="#3a3a5a"
placeholderTextColor={theme.text.muted}
/>
{suffix && <Text style={S.suffix}>{suffix}</Text>}
{suffix && <Text style={[S.suffix, { color: theme.text.muted }]}>{suffix}</Text>}
</View>
{invalid && (
<Text style={S.hint}>{min}–{max}</Text>
<Text style={[S.hint, { color: theme.red.fg }]}>{min}–{max}</Text>
)}
</View>
);
}
function SectionHeader({ title }: { title: string }) {
const theme = useTheme();
return (
<View style={S.sectionHeader}>
<Text style={S.sectionTitle}>{title}</Text>
<Text style={[S.sectionTitle, { color: theme.text.muted }]}>{title}</Text>
</View>
);
}
@@ -94,6 +109,7 @@ function SectionHeader({ title }: { title: string }) {
interface FormProps { onAnyChange?: () => void }
export function ParamForm({ onAnyChange }: FormProps = {}) {
const theme = useTheme();
const { config, updateConfig } = useDeviceStore();
const patch = (partial: Partial<typeof config>) => {
updateConfig(partial);
@@ -101,24 +117,24 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
};
return (
<View style={S.container}>
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
<SectionHeader title="采集配置" />
{/* Channel count */}
<View style={S.row}>
<Text style={S.rowLabel}>通道数</Text>
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
<Text style={[S.rowLabel, { color: theme.text.muted }]}>通道数</Text>
<View style={S.channelRow}>
{[1, 2, 3, 4, 5, 6].map((n) => {
const active = config.channelNum === n;
return (
<TouchableOpacity
key={n}
style={[S.chChip, active && S.chChipActive]}
style={[S.chChip, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }, active && { borderColor: theme.blue.fg + '66', backgroundColor: theme.blue.bg }]}
onPress={() => patch({ channelNum: n })}
activeOpacity={0.7}
>
<Text style={[S.chText, active && S.chTextActive]}>{n}</Text>
<Text style={[S.chText, { color: theme.text.muted }, active && { color: theme.blue.fg }]}>{n}</Text>
</TouchableOpacity>
);
})}
@@ -147,22 +163,22 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
<SectionHeader title="高级设置" />
{/* Reverse accumulation */}
<View style={S.row}>
<Text style={S.rowLabel}>反向叠加</Text>
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
<Text style={[S.rowLabel, { color: theme.text.muted }]}>反向叠加</Text>
<View style={S.switchRow}>
<Text style={S.switchLabel}>CH1-3</Text>
<Text style={[S.switchLabel, { color: theme.text.muted }]}>CH1-3</Text>
<Switch
value={config.negAcc123}
onValueChange={(v) => patch({ negAcc123: v })}
thumbColor={config.negAcc123 ? '#4a9eff' : '#444'}
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
thumbColor={config.negAcc123 ? theme.blue.fg : theme.text.muted}
trackColor={{ false: theme.bg.border, true: theme.blue.border }}
/>
<Text style={S.switchLabel}>CH4-6</Text>
<Text style={[S.switchLabel, { color: theme.text.muted }]}>CH4-6</Text>
<Switch
value={config.negAcc456}
onValueChange={(v) => patch({ negAcc456: v })}
thumbColor={config.negAcc456 ? '#4a9eff' : '#444'}
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
thumbColor={config.negAcc456 ? theme.blue.fg : theme.text.muted}
trackColor={{ false: theme.bg.border, true: theme.blue.border }}
/>
</View>
</View>
@@ -175,16 +191,16 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
/>
{/* File prefix */}
<View style={S.row}>
<Text style={S.rowLabel}>文件前缀</Text>
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
<Text style={[S.rowLabel, { color: theme.text.muted }]}>文件前缀</Text>
<View style={S.inputWrap}>
<TextInput
style={S.input}
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }]}
value={config.filePrefix}
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
maxLength={15}
autoCapitalize="none"
placeholderTextColor="#3a3a5a"
placeholderTextColor={theme.text.muted}
/>
</View>
</View>
@@ -194,7 +210,7 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
}
const S = StyleSheet.create({
container: { backgroundColor: '#090912' },
container: {},
sectionHeader: {
paddingHorizontal: 16,
@@ -202,7 +218,6 @@ const S = StyleSheet.create({
paddingBottom: 6,
},
sectionTitle: {
color: '#3a3a5a',
fontSize: 9,
fontWeight: '700',
letterSpacing: 2,
@@ -215,34 +230,27 @@ const S = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#141422',
gap: 12,
backgroundColor: '#0e0e1c',
marginHorizontal: 0,
},
rowLabel: { color: '#4a4a6a', fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 },
rowLabel: { fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 },
// Picker chips
optScroll: { flex: 1 },
optChip: { borderWidth: 1, borderColor: '#1e1e30', borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
optChipActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
optText: { color: '#3a3a5a', fontSize: 11 },
optTextActive: { color: '#4a9eff', fontWeight: '700' },
optChip: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
optText: { fontSize: 11 },
// Number input
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
input: {
flex: 1,
color: '#c0c0d8',
fontSize: 13,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: '#1e1e30',
},
inputInvalid: { borderBottomColor: '#ff5c6e' },
suffix: { color: '#3a3a5a', fontSize: 10, marginLeft: 8 },
hint: { color: '#ff5c6e', fontSize: 9, marginLeft: 4 },
suffix: { fontSize: 10, marginLeft: 8 },
hint: { fontSize: 9, marginLeft: 4 },
// Channel chips
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
@@ -251,16 +259,12 @@ const S = StyleSheet.create({
height: 30,
borderRadius: 15,
borderWidth: 1,
borderColor: '#1e1e30',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0c0c18',
},
chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' },
chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' },
chTextActive: { color: '#4a9eff' },
chText: { fontSize: 12, fontWeight: '700' },
// Switch row
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
switchLabel: { color: '#4a4a6a', fontSize: 12 },
switchLabel: { fontSize: 12 },
});
+144
View File
@@ -0,0 +1,144 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View, Text, TouchableOpacity, Modal, FlatList,
StyleSheet, ActivityIndicator, Platform,
} from 'react-native';
import * as StorageService from '../services/StorageService';
import type { SessionInfo } from '../services/StorageService';
import { useTheme } from '../design/tokens';
import { Radius } from '../design/tokens';
interface Props {
selectedSessionId: string;
projectId: string;
onSelect: (sessionId: string) => void;
}
export function SessionSelector({ selectedSessionId, projectId, onSelect }: Props) {
const theme = useTheme();
const [visible, setVisible] = useState(false);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(false);
const current = sessions.find((s) => s.sessionId === selectedSessionId);
const reload = useCallback(async () => {
setLoading(true);
const data = await StorageService.listSessionsByProject(projectId);
setSessions(data);
setLoading(false);
}, [projectId]);
useEffect(() => { void reload(); }, [reload]);
const handleOpen = () => {
void reload();
setVisible(true);
};
const handleSelect = (sid: string) => {
setVisible(false);
if (sid !== selectedSessionId) onSelect(sid);
};
return (
<>
<TouchableOpacity
style={[S.trigger, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }]}
onPress={handleOpen}
activeOpacity={0.7}
>
<View style={S.triggerLeft}>
<Text style={[S.label, { color: theme.text.muted }]}>测线</Text>
<Text style={[S.sessionId, { color: theme.text.primary }]} numberOfLines={1}>
{selectedSessionId || '--'}
</Text>
</View>
{current && (
<Text style={[S.count, { color: theme.text.ghost }]}>{current.frameCount} 点</Text>
)}
<Text style={[S.arrow, { color: theme.text.muted }]}>▾</Text>
</TouchableOpacity>
<Modal visible={visible} transparent animationType="slide" onRequestClose={() => setVisible(false)}>
<TouchableOpacity style={S.backdrop} activeOpacity={1} onPress={() => setVisible(false)} />
<View style={[S.sheet, { backgroundColor: theme.bg.surface }]}>
<View style={[S.sheetHandle, { backgroundColor: theme.bg.border }]} />
<Text style={[S.sheetTitle, { color: theme.text.secondary }]}>选择测线</Text>
{loading ? (
<ActivityIndicator style={{ padding: 24 }} color={theme.blue.fg} />
) : sessions.length === 0 ? (
<Text style={[S.empty, { color: theme.text.ghost }]}>当前工程无测线</Text>
) : (
<FlatList
data={sessions}
keyExtractor={(s) => s.sessionId}
style={{ maxHeight: 400 }}
renderItem={({ item }) => {
const active = item.sessionId === selectedSessionId;
const date = new Date(item.createdAt);
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
return (
<TouchableOpacity
style={[S.item, { borderBottomColor: theme.bg.divider }, active && { backgroundColor: theme.blue.bg }]}
onPress={() => handleSelect(item.sessionId)}
activeOpacity={0.7}
>
{active && <View style={[S.dot, { backgroundColor: theme.blue.fg }]} />}
<View style={S.itemLeft}>
<Text style={[S.itemId, { color: active ? theme.blue.fg : theme.text.primary }]} numberOfLines={1}>
{item.sessionId}
</Text>
<Text style={[S.itemSub, { color: theme.text.muted }]}>
{dateStr} · {item.frameCount} 测点
</Text>
</View>
{active && <Text style={[S.activeLabel, { color: theme.blue.fg }]}>当前</Text>}
</TouchableOpacity>
);
}}
/>
)}
</View>
</Modal>
</>
);
}
const MONO = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const;
const S = StyleSheet.create({
trigger: {
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 8,
borderRadius: Radius.md, borderWidth: 1, gap: 8,
},
triggerLeft: { flex: 1, gap: 2 },
label: { fontSize: 9, fontWeight: '600' },
sessionId: { fontSize: 11, fontWeight: '600', ...MONO },
count: { fontSize: 10, ...MONO },
arrow: { fontSize: 12 },
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute', bottom: 0, left: 0, right: 0,
maxHeight: '70%',
borderTopLeftRadius: 20, borderTopRightRadius: 20,
paddingBottom: 32,
},
sheetHandle: { width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 10, marginBottom: 10 },
sheetTitle: { fontSize: 14, fontWeight: '700', paddingHorizontal: 16, paddingBottom: 10 },
empty: { fontSize: 12, textAlign: 'center', paddingVertical: 24 },
item: {
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 16, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth, gap: 8,
},
dot: { width: 6, height: 6, borderRadius: 3 },
itemLeft: { flex: 1, gap: 2 },
itemId: { fontSize: 12, fontWeight: '600', ...MONO },
itemSub: { fontSize: 10 },
activeLabel:{ fontSize: 10, fontWeight: '700' },
});
+200 -94
View File
@@ -1,7 +1,10 @@
import React, { useMemo } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import React, { useMemo, useState, useCallback } from 'react';
import { View, Text } from 'react-native';
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSharedValue, runOnJS } from 'react-native-reanimated';
import { CHANNEL_COLORS } from '../protocol/constants';
import { useTheme } from '../design/tokens';
import type { WaveformData } from '../hooks/useWaveform';
interface Props {
@@ -9,14 +12,14 @@ interface Props {
visibleChannels: boolean[];
width: number;
height: number;
logScale?: boolean; // default true
logScale?: boolean;
interactive?: boolean;
}
const PADDING = { top: 14, right: 14, bottom: 34, left: 56 };
const LOG_Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
const X_TICK_COUNT = 5;
// Pick a "nice" step for axis ticks.
function niceStep(range: number, targetCount: number): number {
const rough = range / targetCount;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
@@ -26,11 +29,13 @@ function niceStep(range: number, targetCount: number): number {
return 10 * mag;
}
function xTicks(totalMs: number): number[] {
if (totalMs <= 0) return [];
const step = niceStep(totalMs, X_TICK_COUNT);
function xTicks(minMs: number, maxMs: number): number[] {
const range = maxMs - minMs;
if (range <= 0) return [];
const step = niceStep(range, X_TICK_COUNT);
const start = Math.ceil(minMs / step) * step;
const ticks: number[] = [];
for (let t = step; t < totalMs * 0.99; t += step) ticks.push(t);
for (let t = start; t <= maxMs * 1.001; t += step) ticks.push(t);
return ticks;
}
@@ -64,19 +69,110 @@ function formatMsShort(ms: number): string {
return `${(ms * 1000).toFixed(0)}μs`;
}
export function WaveformChart({ data, visibleChannels, width, height, logScale = true }: Props) {
export function WaveformChart({ data, visibleChannels, width, height, logScale = true, interactive = false }: Props) {
const theme = useTheme();
const C = theme.chart;
const plotW = width - PADDING.left - PADDING.right;
const plotH = height - PADDING.top - PADDING.bottom;
// ── Zoom & pan state (JS thread for rendering) ───────────────────────────
const [view, setView] = useState({ sx: 1, sy: 1, ox: 0, oy: 0 });
const { sx: scaleX, sy: scaleY, ox: offsetX, oy: offsetY } = view;
// ── Shared values for gesture worklets ───────────────────────────────────
const savedOx = useSharedValue(0);
const savedOy = useSharedValue(0);
const savedSx = useSharedValue(1);
const savedSy = useSharedValue(1);
const curSx = useSharedValue(1);
const applyView = useCallback((sx: number, sy: number, ox: number, oy: number) => {
setView({ sx, sy, ox, oy });
}, []);
const resetView = useCallback(() => {
savedOx.value = 0; savedOy.value = 0;
savedSx.value = 1; savedSy.value = 1;
curSx.value = 1;
setView({ sx: 1, sy: 1, ox: 0, oy: 0 });
}, []);
// ── Gestures ─────────────────────────────────────────────────────────────
const panGesture = Gesture.Pan()
.onStart(() => {
'worklet';
savedOx.value = savedOx.value + 0; // capture
savedOy.value = savedOy.value + 0;
})
.onUpdate((e) => {
'worklet';
const ox = savedOx.value + e.translationX / curSx.value;
const oy = savedOy.value + e.translationY / curSx.value;
runOnJS(applyView)(curSx.value, curSx.value, ox, oy);
})
.onEnd(() => {
'worklet';
})
.minPointers(1)
.maxPointers(1);
const pinchGesture = Gesture.Pinch()
.onStart(() => {
'worklet';
savedSx.value = curSx.value;
})
.onUpdate((e) => {
'worklet';
const ns = Math.max(0.5, Math.min(savedSx.value * e.scale, 50));
curSx.value = ns;
runOnJS(applyView)(ns, ns, savedOx.value, savedOy.value);
});
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
'worklet';
runOnJS(resetView)();
});
// Sync shared values when JS state settles (after pan end)
const panEndGesture = Gesture.Pan()
.onEnd((e) => {
'worklet';
savedOx.value = savedOx.value + e.translationX / curSx.value;
savedOy.value = savedOy.value + e.translationY / curSx.value;
})
.minPointers(1)
.maxPointers(1);
const composed = Gesture.Simultaneous(
Gesture.Simultaneous(panGesture, panEndGesture),
pinchGesture,
doubleTapGesture,
);
// ── Visible data range ───────────────────────────────────────────────────
const totalTimeMs = data.totalTimeMs;
const visibleTimeCenterMs = totalTimeMs / 2 - offsetX / plotW * totalTimeMs;
const visibleTimeSpanMs = totalTimeMs / scaleX;
const xMinMs = Math.max(0, visibleTimeCenterMs - visibleTimeSpanMs / 2);
const xMaxMs = Math.min(totalTimeMs, visibleTimeCenterMs + visibleTimeSpanMs / 2);
// ── Y mapping ────────────────────────────────────────────────────────────
const yLogMin = data.minLogUV - 0.5;
const yLogMax = data.maxLogUV + 0.5;
const yLogRange = yLogMax - yLogMin;
const yLinMin = data.minUV;
const yLinMax = data.maxUV;
const baseYMin = data.minUV;
const baseYMax = data.maxUV;
const baseYRange = baseYMax - baseYMin;
const yCenter = (baseYMin + baseYMax) / 2 - (offsetY / plotH) * baseYRange;
const ySpan = baseYRange / scaleY;
const yLinMin = yCenter - ySpan / 2;
const yLinMax = yCenter + ySpan / 2;
const yLinRange = Math.max(yLinMax - yLinMin, 1e-9);
const yLogRange = yLogMax - yLogMin;
const toY = useMemo(() => {
if (logScale) {
return (uv: number): number => {
@@ -91,11 +187,8 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
}, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]);
// ── X mapping ────────────────────────────────────────────────────────────
const toX = (i: number, total: number): number =>
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
const toXms = (ms: number): number =>
PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW;
const toX = (ms: number): number =>
PADDING.left + ((ms - xMinMs) / Math.max(xMaxMs - xMinMs, 1e-9)) * plotW;
// ── Grid / tick values ──────────────────────────────────────────────────
const yGridValues = useMemo(() => {
@@ -108,7 +201,7 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
return linYTicks(yLinMin, yLinMax);
}, [logScale, yLogMin, yLogMax, yLinMin, yLinMax]);
const xTickValues = useMemo(() => xTicks(data.totalTimeMs), [data.totalTimeMs]);
const xTickValues = useMemo(() => xTicks(xMinMs, xMaxMs), [xMinMs, xMaxMs]);
// ── Channel paths ────────────────────────────────────────────────────────
const channelPaths = useMemo(() => {
@@ -116,110 +209,123 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
if (!visibleChannels[idx]) return null;
const path = Skia.Path.Make();
let moved = false;
const timeStep = data.totalTimeMs / Math.max(ch.length - 1, 1);
for (let i = 0; i < ch.length; i++) {
const ms = i * timeStep;
if (ms < xMinMs || ms > xMaxMs) continue;
if (logScale && Math.abs(ch[i]) < 1e-12) continue;
const x = toX(i, ch.length);
const x = toX(ms);
const y = toY(ch[i]);
if (!isFinite(y)) continue;
if (!isFinite(y) || x < PADDING.left || x > PADDING.left + plotW) continue;
if (!moved) { path.moveTo(x, y); moved = true; }
else path.lineTo(x, y);
}
return path;
});
}, [data, visibleChannels, toY, plotW, plotH]);
}, [data, visibleChannels, toY, xMinMs, xMaxMs, plotW, plotH, logScale]);
const top = PADDING.top;
const bot = PADDING.top + plotH;
const left = PADDING.left;
const right = PADDING.left + plotW;
return (
<View style={[S.container, { width, height }]}>
<Canvas style={{ width, height }}>
const chartContent = (
<View style={[{ backgroundColor: C.bg, width, height, position: 'relative' as const }]}>
<Canvas style={{ width, height }}>
{/* Y grid lines */}
{/* Y grid lines */}
{yGridValues.map((v) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
return (
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
color={C.grid} strokeWidth={0.5} />
);
})}
{/* X grid lines */}
{xTickValues.map((ms) => {
const x = toX(ms);
if (x < left - 1 || x > right + 1) return null;
return (
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
color={C.gridFine} strokeWidth={0.5} />
);
})}
{/* Baseline */}
{!logScale && (() => {
const y0 = toY(0);
return isFinite(y0) && y0 >= top && y0 <= bot ? (
<Line p1={vec(left, y0)} p2={vec(right, y0)}
color={C.axis} strokeWidth={0.8} />
) : null;
})()}
{/* X axis */}
<Line p1={vec(left, bot)} p2={vec(right, bot)}
color={C.axis} strokeWidth={1} />
{/* Channel paths */}
{channelPaths.map((path, idx) => {
if (!path || !visibleChannels[idx]) return null;
return (
<Path key={idx} path={path}
color={CHANNEL_COLORS[idx]}
style="stroke" strokeWidth={interactive ? 2.5 : 1.5}
strokeJoin="round" strokeCap="round" />
);
})}
</Canvas>
{/* Y-axis labels */}
{yGridValues.map((v) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
const label = logScale ? formatLogTick(v) : formatUVShort(v);
return (
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
color="#333333" strokeWidth={0.5} />
<Text key={v}
style={{ position: 'absolute', top: y - 7, left: 2, width: PADDING.left - 4, color: C.label, fontSize: 9, textAlign: 'right' }}>
{label}
</Text>
);
})}
{/* X grid lines */}
{/* X-axis tick labels */}
{xTickValues.map((ms) => {
const x = toXms(ms);
const x = toX(ms);
if (x < left - 10 || x > right + 10) return null;
return (
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
color="#2a2a2a" strokeWidth={0.5} />
<Text key={ms}
style={{ position: 'absolute', top: height - 20, left: x - 18, width: 36, color: C.label, fontSize: 9, textAlign: 'center' }}>
{formatMsShort(ms)}
</Text>
);
})}
{/* Baseline */}
{!logScale && (
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))}
color="#555555" strokeWidth={0.8} />
{/* Axis unit labels */}
<Text style={{ position: 'absolute', top: 2, left: 2, color: C.labelDim, fontSize: 9 }}>μV</Text>
<Text style={{ position: 'absolute', top: height - 14, left: PADDING.left + plotW - 12, color: C.labelDim, fontSize: 9 }}>
{data.totalTimeMs >= 1000 ? 's' : 'ms'}
</Text>
{/* Scale mode badge */}
<Text style={{ position: 'absolute', top: 2, right: 4, color: C.badge, fontSize: 8, fontWeight: '700', letterSpacing: 0.5 }}>
{logScale ? 'LOG' : 'LIN'}
</Text>
{/* Zoom indicator */}
{(scaleX > 1.01 || scaleY > 1.01) && (
<Text style={{ position: 'absolute', top: 2, right: 36, color: C.zoomBadge, fontSize: 8, fontWeight: '700' }}>
×{scaleX.toFixed(1)}
</Text>
)}
{/* X axis */}
<Line p1={vec(left, bot)} p2={vec(right, bot)}
color="#555555" strokeWidth={1} />
{/* Channel 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 */}
{yGridValues.map((v) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
const label = logScale ? formatLogTick(v) : formatUVShort(v);
return (
<Text key={v}
style={[S.yLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}>
{label}
</Text>
);
})}
{/* X-axis tick labels */}
{xTickValues.map((ms) => {
const x = toXms(ms);
return (
<Text key={ms}
style={[S.xLabel, { top: height - 20, left: x - 18, width: 36 }]}>
{formatMsShort(ms)}
</Text>
);
})}
{/* Axis unit labels */}
<Text style={[S.unitLabel, { top: 2, left: 2 }]}>μV</Text>
<Text style={[S.unitLabel, { top: height - 14, left: PADDING.left + plotW - 12 }]}>
{data.totalTimeMs >= 1000 ? 's' : 'ms'}
</Text>
{/* Scale mode badge */}
<Text style={[S.scaleBadge, { top: 2, right: 4 }]}>
{logScale ? 'LOG' : 'LIN'}
</Text>
</View>
</View>
);
if (interactive) {
return <GestureDetector gesture={composed}>{chartContent}</GestureDetector>;
}
return chartContent;
}
const S = StyleSheet.create({
container: { backgroundColor: '#111111', position: 'relative' },
yLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'right' },
xLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'center' },
unitLabel: { position: 'absolute', color: '#555', fontSize: 9 },
scaleBadge: { position: 'absolute', color: '#444', fontSize: 8, fontWeight: '700', letterSpacing: 0.5 },
});
+23 -25
View File
@@ -4,24 +4,26 @@ import { useConnectionStore } from '../../stores/connectionStore';
import { useDeviceStore } from '../../stores/deviceStore';
import { useDataStore } from '../../stores/dataStore';
import { formatBattery, formatTemperature } from '../../utils/format';
import { Colors } from '../../design/tokens';
import { useTheme, type ThemeColors } from '../../design/tokens';
function Pill({
label,
value,
color,
theme,
onPress,
}: {
label: string;
value: string;
color: string;
theme: ThemeColors;
onPress?: () => void;
}) {
const inner = (
<View style={[S.pill, { borderColor: color + '44' }]}>
<View style={[S.dot, { backgroundColor: color }]} />
<Text style={S.pillLabel}>{label}</Text>
<Text style={[S.pillValue, { color }]}>{value}</Text>
<View style={[styles.pill, { backgroundColor: theme.bg.raised, borderColor: color + '44' }]}>
<View style={[styles.dot, { backgroundColor: color }]} />
<Text style={[styles.pillLabel, { color: theme.text.ghost }]}>{label}</Text>
<Text style={[styles.pillValue, { color }]}>{value}</Text>
</View>
);
if (onPress) return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{inner}</TouchableOpacity>;
@@ -29,91 +31,87 @@ function Pill({
}
export function GlobalStatusBar() {
const theme = useTheme();
const { status, showModal } = useConnectionStore();
const { deviceStatus, batteryVolt, temperature, gpsStatus, sdStatus } = useDeviceStore();
const frame = useDataStore((s) => s.currentFrame);
const meta = frame?.meta;
const connected = status === 'connected';
const connColor = connected ? Colors.green.fg : status === 'connecting' ? Colors.amber.fg : Colors.text.ghost;
const connColor = connected ? theme.green.fg : status === 'connecting' ? theme.amber.fg : theme.text.ghost;
const connLabel = connected ? '已连接' : status === 'connecting' ? '连接中' : status === 'reconnecting' ? '重连' : '未连接';
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
const hasSd = sdStatus > 0;
const hasSd = sdStatus === 0;
const running = deviceStatus === 'running';
const single = deviceStatus === 'single';
const stateColor = running ? Colors.green.fg : single ? Colors.teal.fg : Colors.text.ghost;
const stateColor = running ? theme.green.fg : single ? theme.teal.fg : theme.text.ghost;
const stateLabel = running ? '● 运行' : single ? '◎ 单次' : '○ 停止';
return (
<View style={S.bar}>
<View style={[styles.bar, { backgroundColor: theme.bg.void, borderBottomColor: theme.bg.border }]}>
{/* Connection — tappable */}
<Pill label="TCP" value={connLabel} color={connColor} onPress={showModal} />
<Pill label="TCP" value={connLabel} color={connColor} theme={theme} onPress={showModal} />
{/* Device run state */}
{connected && (
<View style={[S.pill, { borderColor: stateColor + '44' }]}>
<Text style={[S.stateText, { color: stateColor }]}>{stateLabel}</Text>
<View style={[styles.pill, { backgroundColor: theme.bg.raised, borderColor: stateColor + '44' }]}>
<Text style={[styles.stateText, { color: stateColor }]}>{stateLabel}</Text>
</View>
)}
{/* GPS */}
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? Colors.green.fg : Colors.text.ghost} />
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? theme.green.fg : theme.text.ghost} theme={theme} />
{/* SD */}
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? Colors.blue.fg : Colors.text.ghost} />
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? theme.blue.fg : theme.text.ghost} theme={theme} />
{/* Battery */}
{batteryVolt > 0 && (
<Pill label="电量" value={formatBattery(batteryVolt)} color={Colors.text.muted} />
<Pill label="电量" value={formatBattery(batteryVolt)} color={theme.text.muted} theme={theme} />
)}
{/* Temperature */}
{temperature > 0 && (
<Pill label="温度" value={formatTemperature(temperature)} color={Colors.text.muted} />
<Pill label="温度" value={formatTemperature(temperature)} color={theme.text.muted} theme={theme} />
)}
{/* Frame counter — right-aligned */}
{frame && (
<View style={S.frameCount}>
<Text style={S.frameCountText}>#{frame.frameId}</Text>
<View style={styles.frameCount}>
<Text style={[styles.frameCountText, { color: theme.text.ghost }]}>#{frame.frameId}</Text>
</View>
)}
</View>
);
}
const S = StyleSheet.create({
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
backgroundColor: Colors.bg.void,
paddingHorizontal: 10,
paddingVertical: 7,
alignItems: 'center',
flexWrap: 'wrap',
gap: 5,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.border,
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: Colors.bg.raised,
borderRadius: 5,
borderWidth: 1,
borderColor: Colors.bg.border,
paddingHorizontal: 6,
paddingVertical: 2,
},
dot: { width: 5, height: 5, borderRadius: 3 },
pillLabel: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
pillLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
pillValue: { fontSize: 9, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
stateText: { fontSize: 10, fontWeight: '700', letterSpacing: 0.3 },
frameCount: { marginLeft: 'auto' as any },
frameCountText: {
color: Colors.text.ghost,
fontSize: 10,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
},
+105 -62
View File
@@ -6,11 +6,13 @@ import {
} from 'react-native';
import { useConnectionStore } from '../../stores/connectionStore';
import { useDevice } from '../../hooks/useDevice';
import { Colors, Radius, Spacing } from '../../design/tokens';
import { useTheme } from '../../design/tokens';
import { Radius, Spacing } from '../../design/tokens';
const LOG_MAX = 20;
export function ConnectModal() {
const theme = useTheme();
const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
useConnectionStore();
const { connect, disconnect } = useDevice();
@@ -39,10 +41,17 @@ export function ConnectModal() {
}, [status, lastError]);
const handleConnect = () => {
const trimmed = host.trim();
const p = parseInt(portStr, 10);
if (!host.trim() || isNaN(p)) { addLog('请检查 IP 和端口'); return; }
const ipRe = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!trimmed || !ipRe.test(trimmed) || trimmed.split('.').some(s => Number(s) > 255)) {
addLog('IP 地址格式无效'); return;
}
if (isNaN(p) || p < 1 || p > 65535) {
addLog('端口范围 1-65535'); return;
}
setPort(p);
addLog(`连接 ${host}:${p} …`);
addLog(`连接 ${trimmed}:${p} …`);
connect();
};
@@ -69,51 +78,79 @@ export function ConnectModal() {
style={S.kav}
pointerEvents="box-none"
>
<View style={S.sheet}>
<View style={[S.sheet, {
backgroundColor: theme.bg.surface,
borderColor: theme.bg.border,
}]}>
{/* Handle */}
<View style={S.handle} />
<View style={[S.handle, { backgroundColor: theme.bg.border }]} />
{/* Title */}
<Text style={S.title}>连接仪器</Text>
<Text style={[S.title, { color: theme.text.secondary }]}>连接仪器</Text>
{/* Guide */}
<View style={S.guideRow}>
<View style={S.stepBadge}><Text style={S.stepNum}>1</Text></View>
<Text style={S.stepTxt}>手机连接设备 WiFi 热点</Text>
<TouchableOpacity style={S.settingsBtn} onPress={() => Linking.openSettings()}>
<Text style={S.settingsTxt}>设置 →</Text>
<View style={[S.stepBadge, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}>
<Text style={[S.stepNum, { color: theme.blue.fg }]}>1</Text>
</View>
<Text style={[S.stepTxt, { color: theme.text.muted }]}>手机连接设备 WiFi 热点</Text>
<TouchableOpacity
style={[S.settingsBtn, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}
onPress={() => Linking.openSettings()}
>
<Text style={[S.settingsTxt, { color: theme.blue.fg }]}>设置 →</Text>
</TouchableOpacity>
</View>
<View style={[S.guideRow, { marginBottom: Spacing.md }]}>
<View style={S.stepBadge}><Text style={S.stepNum}>2</Text></View>
<Text style={S.stepTxt}>确认参数后点击连接</Text>
<View style={[S.stepBadge, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}>
<Text style={[S.stepNum, { color: theme.blue.fg }]}>2</Text>
</View>
<Text style={[S.stepTxt, { color: theme.text.muted }]}>确认参数后点击连接</Text>
</View>
{/* Inputs */}
<View style={S.inputCard}>
<View style={[S.inputCard, {
backgroundColor: theme.bg.raised,
borderColor: theme.bg.border,
}]}>
<View style={S.inputRow}>
<Text style={S.inputLabel}>IP 地址</Text>
<Text style={[S.inputLabel, { color: theme.text.muted }]}>IP 地址</Text>
<TextInput
style={S.input}
style={[S.input, {
color: theme.text.primary,
borderBottomColor: theme.bg.border,
}]}
value={host}
onChangeText={setHost}
keyboardType="numeric"
placeholder="192.168.4.1"
placeholderTextColor={Colors.text.ghost}
placeholderTextColor={theme.text.ghost}
autoCapitalize="none"
editable={!connecting}
/>
</View>
<View style={S.inputDivider} />
<View style={[S.inputDivider, { backgroundColor: theme.bg.divider }]} />
<View style={S.inputRow}>
<Text style={S.inputLabel}>端 口</Text>
<Text style={[S.inputLabel, { color: theme.text.muted }]}>端 口</Text>
<TextInput
style={S.input}
style={[S.input, {
color: theme.text.primary,
borderBottomColor: theme.bg.border,
}]}
value={portStr}
onChangeText={setPortStr}
keyboardType="numeric"
placeholder="4321"
placeholderTextColor={Colors.text.ghost}
placeholderTextColor={theme.text.ghost}
editable={!connecting}
/>
</View>
@@ -122,33 +159,51 @@ export function ConnectModal() {
{/* Action */}
{!connected ? (
<TouchableOpacity
style={[S.connectBtn, connecting && S.connectBtnBusy]}
style={[S.connectBtn, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.fg,
}, connecting && {
borderColor: theme.blue.border,
backgroundColor: theme.bg.raised,
}]}
onPress={handleConnect}
disabled={connecting}
activeOpacity={0.8}
>
{connecting
? <ActivityIndicator color={Colors.blue.fg} />
: <Text style={S.connectBtnTxt}>连 接 设 备</Text>}
? <ActivityIndicator color={theme.blue.fg} />
: <Text style={[S.connectBtnTxt, { color: theme.blue.fg }]}>连 接 设 备</Text>}
</TouchableOpacity>
) : (
<View style={S.connectedRow}>
<View style={S.connectedLeft}>
<View style={S.greenDot} />
<Text style={S.connectedTxt}>已连接 {host}:{port}</Text>
<View style={[S.greenDot, { backgroundColor: theme.green.fg }]} />
<Text style={[S.connectedTxt, { color: theme.green.fg }]}>已连接 {host}:{port}</Text>
</View>
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect}>
<Text style={S.disconnectTxt}>断 开</Text>
<TouchableOpacity
style={[S.disconnectBtn, {
backgroundColor: theme.red.bg,
borderColor: theme.red.border,
}]}
onPress={handleDisconnect}
>
<Text style={[S.disconnectTxt, { color: theme.red.fg }]}>断 开</Text>
</TouchableOpacity>
</View>
)}
{/* Log */}
<View style={S.logBox}>
<Text style={S.logHeader}>● 连接日志</Text>
<View style={[S.logBox, {
backgroundColor: theme.bg.void,
borderColor: theme.bg.border,
}]}>
<Text style={[S.logHeader, {
color: theme.green.fg,
borderBottomColor: theme.bg.divider,
}]}>● 连接日志</Text>
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
{logs.map((l, i) => (
<Text key={i} style={[S.logLine, i === 0 && S.logLineLatest]}>{l}</Text>
<Text key={i} style={[S.logLine, { color: theme.text.ghost }, i === 0 && { color: theme.text.muted }]}>{l}</Text>
))}
</ScrollView>
</View>
@@ -168,22 +223,18 @@ const S = StyleSheet.create({
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: Colors.bg.surface,
borderTopLeftRadius: Radius.xl,
borderTopRightRadius: Radius.xl,
padding: Spacing.lg,
paddingBottom: Spacing.xl,
borderTopWidth: 1,
borderColor: Colors.bg.border,
},
handle: {
width: 36, height: 4, borderRadius: 2,
backgroundColor: Colors.bg.border,
alignSelf: 'center',
marginBottom: Spacing.md,
},
title: {
color: Colors.text.secondary,
fontSize: 15,
fontWeight: '700',
marginBottom: Spacing.md,
@@ -197,81 +248,73 @@ const S = StyleSheet.create({
},
stepBadge: {
width: 20, height: 20, borderRadius: 10,
backgroundColor: Colors.blue.bg,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1, borderColor: Colors.blue.border,
borderWidth: 1,
},
stepNum: { color: Colors.blue.fg, fontSize: 10, fontWeight: '700' },
stepTxt: { color: Colors.text.muted, fontSize: 12, flex: 1 },
stepNum: { fontSize: 10, fontWeight: '700' },
stepTxt: { fontSize: 12, flex: 1 },
settingsBtn: {
backgroundColor: Colors.blue.bg, borderRadius: Radius.sm,
borderRadius: Radius.sm,
paddingHorizontal: 8, paddingVertical: 3,
borderWidth: 1, borderColor: Colors.blue.border,
borderWidth: 1,
},
settingsTxt: { color: Colors.blue.fg, fontSize: 11, fontWeight: '600' },
settingsTxt: { fontSize: 11, fontWeight: '600' },
inputCard: {
backgroundColor: Colors.bg.raised,
borderRadius: Radius.md,
borderWidth: 1, borderColor: Colors.bg.border,
borderWidth: 1,
paddingHorizontal: Spacing.md,
marginBottom: Spacing.md,
},
inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm },
inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: Colors.bg.divider },
inputLabel: { color: Colors.text.muted, fontSize: 12, width: 52 },
inputDivider: { height: StyleSheet.hairlineWidth },
inputLabel: { fontSize: 12, width: 52 },
input: {
flex: 1,
color: Colors.text.primary,
fontSize: 15,
fontWeight: '500',
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.border,
},
connectBtn: {
backgroundColor: Colors.blue.bg,
borderRadius: Radius.lg,
paddingVertical: 15,
alignItems: 'center',
marginBottom: Spacing.md,
borderWidth: 1, borderColor: Colors.blue.fg,
borderWidth: 1,
},
connectBtnBusy: { borderColor: Colors.blue.border, backgroundColor: Colors.bg.raised },
connectBtnTxt: { color: Colors.blue.fg, fontSize: 15, fontWeight: '700', letterSpacing: 3 },
connectBtnTxt: { fontSize: 15, fontWeight: '700', letterSpacing: 3 },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md },
connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: Colors.green.fg },
greenDot: { width: 8, height: 8, borderRadius: 4 },
connectedTxt: {
color: Colors.green.fg, fontWeight: '700', fontSize: 12,
fontWeight: '700', fontSize: 12,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
},
disconnectBtn: {
backgroundColor: Colors.red.bg, borderRadius: Radius.md,
borderRadius: Radius.md,
paddingVertical: 8, paddingHorizontal: 14,
borderWidth: 1, borderColor: Colors.red.border,
borderWidth: 1,
},
disconnectTxt: { color: Colors.red.fg, fontSize: 12, fontWeight: '700', letterSpacing: 1 },
disconnectTxt: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
logBox: {
backgroundColor: Colors.bg.void,
borderRadius: Radius.md,
borderWidth: 1, borderColor: Colors.bg.border,
borderWidth: 1,
overflow: 'hidden',
maxHeight: 120,
},
logHeader: {
color: Colors.green.fg, fontSize: 9, fontWeight: '700', letterSpacing: 1.5,
fontSize: 9, fontWeight: '700', letterSpacing: 1.5,
paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5,
borderBottomWidth: 1, borderBottomColor: Colors.bg.divider,
borderBottomWidth: 1,
},
logScroll: { padding: Spacing.sm },
logLine: {
color: Colors.text.ghost, fontSize: 10,
fontSize: 10,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
marginBottom: 2, lineHeight: 15,
},
logLineLatest: { color: Colors.text.muted },
});
+62 -1
View File
@@ -1,4 +1,8 @@
export const Colors = {
import { useColorScheme } from 'react-native';
// ── Color palettes ──────────────────────────────────────────────────────────
const DarkColors = {
bg: {
void: '#050508',
base: '#090912',
@@ -18,8 +22,65 @@ export const Colors = {
red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' },
teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' },
amber: { bg: '#1e1a08', border: '#4a3a1a', fg: '#ffd93d' },
chart: {
bg: '#111111',
grid: '#333333',
gridFine: '#2a2a2a',
axis: '#555555',
label: '#888888',
labelDim: '#555555',
badge: '#444444',
zoomBadge:'#666666',
},
} as const;
const LightColors = {
bg: {
void: '#f0f0f4',
base: '#f5f5f8',
surface: '#ffffff',
raised: '#f0f0f5',
border: '#d8d8e0',
divider: '#e8e8f0',
},
text: {
primary: '#1a1a2e',
secondary: '#3a3a5a',
muted: '#8a8aa0',
ghost: '#b0b0c0',
},
green: { bg: '#e8f5e9', border: '#a5d6a7', fg: '#2e7d32' },
blue: { bg: '#e3f2fd', border: '#90caf9', fg: '#1565c0' },
red: { bg: '#fce4ec', border: '#ef9a9a', fg: '#c62828' },
teal: { bg: '#e0f2f1', border: '#80cbc4', fg: '#00695c' },
amber: { bg: '#fff8e1', border: '#ffe082', fg: '#f57f17' },
chart: {
bg: '#ffffff',
grid: '#e0e0e0',
gridFine: '#eeeeee',
axis: '#aaaaaa',
label: '#666666',
labelDim: '#999999',
badge: '#999999',
zoomBadge:'#777777',
},
} as const;
export type ThemeColors = typeof DarkColors;
// ── Hook ────────────────────────────────────────────────────────────────────
export function useTheme(): ThemeColors {
const scheme = useColorScheme();
return scheme === 'light' ? LightColors : DarkColors;
}
// ── Static export (for non-component code that can't use hooks) ─────────────
export const Colors = DarkColors;
// ── Spacing / Radius / FontSize ─────────────────────────────────────────────
export const Spacing = {
xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32,
} as const;
+27 -7
View File
@@ -1,7 +1,7 @@
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 { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop, flushPendingData } from '../services/DeviceService';
import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import type { SetupConfig } from '../protocol/types';
@@ -32,7 +32,16 @@ export function useDevice() {
useDeviceStore.getState().setDeviceStatus('idle');
}, []);
const checkConn = () => {
if (!tcpService.isConnected()) {
Alert.alert('未连接', '请先连接设备');
return false;
}
return true;
};
const setup = useCallback(async (cfg?: SetupConfig) => {
if (!checkConn()) return;
const config = cfg ?? useDeviceStore.getState().config;
setBusy(true);
try {
@@ -41,6 +50,7 @@ export function useDevice() {
Alert.alert('配置失败', ack.reason || '设备返回失败');
} else {
useDeviceStore.getState().setConfigDirty(false);
Alert.alert('配置成功', '参数已下发到设备');
}
return ack;
} catch (e: any) {
@@ -51,6 +61,7 @@ export function useDevice() {
}, []);
const startContinuous = useCallback(async () => {
if (!checkConn()) return;
setBusy(true);
try {
const ack = await deviceStartContinuous();
@@ -64,6 +75,7 @@ export function useDevice() {
}, []);
const startSingle = useCallback(async () => {
if (!checkConn()) return;
setBusy(true);
try {
const ack = await deviceStartSingle();
@@ -77,14 +89,22 @@ export function useDevice() {
}, []);
const stop = useCallback(async () => {
// Optimistically transition to idle so the UI responds immediately.
// The ACK is sent best-effort; if the device doesn't reply the state
// is already correct on our side.
setBusy(true);
// Immediately stop processing buffered frames
useDeviceStore.getState().setDeviceStatus('idle');
flushPendingData();
try {
await deviceStop();
} catch {
// best-effort — UI already reflects idle
for (let attempt = 0; attempt < 3; attempt++) {
try {
const ack = await deviceStop();
if (ack.result === 0x01) return;
} catch {
// retry
}
}
Alert.alert('停止采集', '设备未确认,已强制停止');
} finally {
setBusy(false);
}
}, []);
+21 -14
View File
@@ -12,20 +12,21 @@ const SAMPLE_FREQ_HZ: Record<number, number> = {
0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
};
// Peak-hold downsample: preserves signal envelope when reducing points.
function downsample(data: Float64Array, targetLen: number): Float64Array {
if (data.length <= targetLen) return data;
const ratio = data.length / targetLen;
const out = new Float64Array(targetLen);
for (let i = 0; i < targetLen; i++) {
const pairLen = Math.floor(targetLen / 2);
const ratio = data.length / pairLen;
const out = new Float64Array(pairLen * 2);
for (let i = 0; i < pairLen; 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]; }
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
let mn = data[start], mx = data[start];
for (let j = start + 1; j < end; j++) {
if (data[j] < mn) mn = data[j];
if (data[j] > mx) mx = data[j];
}
out[i] = maxVal;
out[i * 2] = mn;
out[i * 2 + 1] = mx;
}
return out;
}
@@ -86,10 +87,16 @@ export function computeWaveformData(
if (!isFinite(linMin)) linMin = -1;
if (!isFinite(linMax)) linMax = 1;
// Symmetric linear range for cleaner display
const linPeak = Math.max(Math.abs(linMin), Math.abs(linMax), 1);
linMin = -linPeak;
linMax = linPeak;
// Auto-range: pad the actual data range by 10% for visual breathing room
const linRange = linMax - linMin;
if (linRange < 1e-9) {
linMin -= 1;
linMax += 1;
} else {
const pad = linRange * 0.1;
linMin -= pad;
linMax += pad;
}
return {
channels, timeMs, totalTimeMs,
+7 -8
View File
@@ -1,5 +1,7 @@
export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
export const FRAME_FLAG_2BYTE = 0xff;
export const FRAME_FLAG_4BYTE = 0xfe;
export const FRAME_TAIL = new Uint8Array([0x68, 0x68]);
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
@@ -10,7 +12,6 @@ export const FuncCode = {
CONTINUOUS_REQ: 0x02,
SINGLE_REQ: 0x03,
STOP_REQ: 0x04,
ACTIVE_REQ: 0x05,
SPLITFRAME_REQ: 0x08,
// Device → App
SETUP_ACK: 0x81,
@@ -33,13 +34,11 @@ export const SEND_FREQ_TABLE = [
{ 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
{ code: 0x05, label: '16 Hz' },
{ code: 0x06, label: '25 Hz' },
{ code: 0x07, label: '32 Hz' },
{ code: 0x08, label: '50 Hz' },
{ code: 0x09, label: '64 Hz' },
] as const;
export const SAMPLE_FREQ_TABLE = [
+76 -45
View File
@@ -1,46 +1,58 @@
import { FRAME_FLAG_4BYTE, FuncCode, DataChannel } from './constants';
import { FRAME_FLAG_2BYTE, FRAME_FLAG_4BYTE, FRAME_TAIL, FuncCode } from './constants';
import type { SetupConfig } from './types';
const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
const HEADER_LEN = 10;
const STANDARD_PAYLOAD_LEN = 54;
const STANDARD_TOTAL_LEN = HEADER_LEN + STANDARD_PAYLOAD_LEN; // 64
// Build the 10-byte packet header
function buildHeader(func: number, payloadLen: number): Uint8Array {
const buf = new Uint8Array(10);
const buf = new Uint8Array(HEADER_LEN);
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;
if (payloadLen <= 0xffff) {
buf[4] = FRAME_FLAG_2BYTE;
buf[6] = payloadLen & 0xff;
buf[7] = (payloadLen >> 8) & 0xff;
buf.set(FRAME_TAIL, 8);
} else {
buf[4] = FRAME_FLAG_4BYTE;
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);
function buildSimpleCommand(func: number, freqFlag = 0): Uint8Array {
const payload = new Uint8Array(STANDARD_PAYLOAD_LEN);
payload[0] = 0x01;
payload[1] = freqFlag & 0xff;
const now = new Date();
const dateVal =
(now.getFullYear() % 100) * 10000 +
(now.getMonth() + 1) * 100 +
now.getDate();
payload[2] = (dateVal >> 24) & 0xff;
payload[3] = (dateVal >> 16) & 0xff;
payload[4] = (dateVal >> 8) & 0xff;
payload[5] = dateVal & 0xff;
const header = buildHeader(func, STANDARD_PAYLOAD_LEN);
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
pkt.set(payload, HEADER_LEN);
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;
@@ -48,50 +60,69 @@ function writeString(buf: Uint8Array, offset: number, str: string, maxLen: numbe
}
export function buildSetupPacket(cfg: SetupConfig): Uint8Array {
const PAYLOAD_LEN = 64; // sizeof(setupReq_t)
const payload = new Uint8Array(PAYLOAD_LEN);
const payload = new Uint8Array(STANDARD_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);
let negAccNum = cfg.accNum & 0x3fff;
if (cfg.negAcc123) negAccNum |= 0x8000;
if (cfg.negAcc456) negAccNum |= 0x4000;
writeUint16LE(payload, 5, negAccNum);
payload[7] = cfg.ampRatio & 0xff;
payload[8] = cfg.dataChannel & 0xff;
payload[9] = cfg.compRes & 0xff;
payload[10] = cfg.secondSampleFreq & 0xff;
payload[10] = cfg.sampleFreq & 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);
const header = buildHeader(FuncCode.SETUP_REQ, STANDARD_PAYLOAD_LEN);
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
pkt.set(payload, HEADER_LEN);
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 const buildContinuousReq = (freqFlag = 0) =>
buildSimpleCommand(FuncCode.CONTINUOUS_REQ, freqFlag);
export const buildSingleReq = (freqFlag = 0) =>
buildSimpleCommand(FuncCode.SINGLE_REQ, freqFlag);
export function buildStopReq(freqFlag = 0): Uint8Array {
const payload = new Uint8Array(STANDARD_PAYLOAD_LEN);
payload[0] = 0x02;
payload[1] = freqFlag & 0xff;
const now = new Date();
const dateVal =
(now.getFullYear() % 100) * 10000 +
(now.getMonth() + 1) * 100 +
now.getDate();
payload[2] = (dateVal >> 24) & 0xff;
payload[3] = (dateVal >> 16) & 0xff;
payload[4] = (dateVal >> 8) & 0xff;
payload[5] = dateVal & 0xff;
const header = buildHeader(FuncCode.STOP_REQ, STANDARD_PAYLOAD_LEN);
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
pkt.set(header, 0);
pkt.set(payload, HEADER_LEN);
return pkt;
}
export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array {
const PAYLOAD_LEN = 54;
const payload = new Uint8Array(PAYLOAD_LEN);
const payload = new Uint8Array(STANDARD_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);
const header = buildHeader(FuncCode.SPLITFRAME_REQ, STANDARD_PAYLOAD_LEN);
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
pkt.set(payload, HEADER_LEN);
return pkt;
}
+38 -17
View File
@@ -8,6 +8,7 @@ export class TemFrameParser {
private buf: Uint8Array;
private len = 0;
private readonly capacity: number;
onOverflow?: () => void;
constructor(capacity = 2 * 1024 * 1024) {
this.capacity = capacity;
@@ -25,8 +26,9 @@ export class TemFrameParser {
}
if (this.len + incoming.length > this.capacity) {
// Buffer overflow — reset and resync
if (__DEV__) console.warn(`[Parser] buffer overflow: ${this.len} + ${incoming.length} > ${this.capacity}, resetting`);
this.len = 0;
this.onOverflow?.();
}
this.buf.set(incoming, this.len);
this.len += incoming.length;
@@ -50,19 +52,31 @@ export class TemFrameParser {
const flag = this.buf[offset + 4];
const func = this.buf[offset + 5];
const payloadLen = this.readUint32LE(offset + 6);
const is2Byte = !!(flag & 0x01);
const payloadLen = is2Byte
? this.readUint16LE(offset + 6)
: this.readUint32LE(offset + 6);
if (__DEV__) console.log(
`[Parser] header @ ${offset}: flag=0x${flag.toString(16)} func=0x${func.toString(16)} lenMode=${is2Byte ? '2B' : '4B'} payloadLen=${payloadLen} bufLen=${this.len}`,
'hdr:', Array.from(this.buf.slice(offset, offset + 10)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '),
);
if (payloadLen > MAX_PAYLOAD_SIZE) {
// Bogus length — skip this magic and search again
if (__DEV__) console.warn(`[Parser] bogus payloadLen ${payloadLen} > MAX ${MAX_PAYLOAD_SIZE}, skipping magic`);
offset += 4;
continue;
}
const totalLen = 10 + payloadLen;
if (this.len - offset < totalLen) break; // wait for more data
if (this.len - offset < totalLen) {
if (__DEV__) console.log(`[Parser] incomplete frame: have ${this.len - offset}, need ${totalLen}`);
break;
}
const payload = new Uint8Array(totalLen - 10);
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
if (__DEV__) console.log(`[Parser] ✓ frame complete: func=0x${func.toString(16)} payload=${payloadLen}B`);
results.push({ flag, func, payload });
offset += totalLen;
}
@@ -91,6 +105,10 @@ export class TemFrameParser {
return -1;
}
private readUint16LE(offset: number): number {
return this.buf[offset] | (this.buf[offset + 1] << 8);
}
private readUint32LE(offset: number): number {
const b = this.buf;
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
@@ -128,22 +146,24 @@ export function parseMetadata(payload: Uint8Array): MeasurementMeta | null {
}
// Parse ADC sample data from a 0x85/0x95 payload into per-channel arrays
const ADC_FULL_SCALE_V = 5.0;
const INT32_MAX = 0x7fffffff;
// raw → μV: (raw / 0x7FFFFFFF) * 5V / gain * 1e6
const ADC_UV_SCALE = (ADC_FULL_SCALE_V * 1e6) / INT32_MAX;
export function parseAdcData(
payload: Uint8Array,
channelNum: number,
accNum: 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 totalSamples = sampleDataLen / 4;
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 gain = AMP_GAIN[payload[30]] ?? 1;
const scale = ADC_UV_SCALE / gain;
const adcRaw: Int32Array[] = [];
const adcUV: Float64Array[] = [];
@@ -152,10 +172,10 @@ export function parseAdcData(
const raw = new Int32Array(samplesPerChannel);
const uv = new Float64Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
const idx = (ch * samplesPerChannel + i) * 4;
const idx = (i * channelNum + ch) * 4;
const val = view.getInt32(idx, true);
raw[i] = val;
uv[i] = val / (accNum || 1) / correctGain;
uv[i] = val * scale;
}
adcRaw.push(raw);
adcUV.push(uv);
@@ -168,11 +188,12 @@ export function parseAdcData(
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]);
if (payload.length > reasonOffset) {
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 };
}
+3 -2
View File
@@ -153,13 +153,14 @@ export function decodeBin(bytes: Uint8Array): LoadedBin | null {
if (bytes.length < expected) return null;
const gain = AMP_GAIN[ampRatio] ?? 1;
const acc = Math.max(accNum, 1);
const ADC_UV_SCALE = (5.0 * 1e6) / 0x7fffffff;
const scale = ADC_UV_SCALE / gain;
const adcUV: Float64Array[] = [];
let off = HEADER_SIZE;
for (let ch = 0; ch < channelNum; ch++) {
const channel = new Float64Array(sampleDepth);
for (let i = 0; i < sampleDepth; i++) {
channel[i] = dv.getInt32(off, true) / acc / gain;
channel[i] = dv.getInt32(off, true) * scale;
off += 4;
}
adcUV.push(channel);
+64 -31
View File
@@ -11,11 +11,12 @@ import {
parseAdcData,
parseAck,
} from '../protocol/parser';
import { FuncCode, SplitFrameCmd, AckResult } from '../protocol/constants';
import { FuncCode, SplitFrameCmd, AckResult, AMP_GAIN } 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';
import * as StorageService from './StorageService';
// Pending ACK promise resolver keyed by func code
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
@@ -27,6 +28,7 @@ let splitFrameNo = 0;
export function handleIncomingFrame(frame: RawFrame) {
const { func, payload } = frame;
if (__DEV__) console.log(`[Device] incoming frame: func=0x${func.toString(16)} payloadLen=${payload.length} pendingAcks=[${[...pendingAcks.keys()].map(k => '0x' + k.toString(16))}]`);
const devStore = useDeviceStore.getState();
const dataStore = useDataStore.getState();
@@ -90,47 +92,60 @@ export function handleIncomingFrame(frame: RawFrame) {
}
}
let _lastUIUpdateTime = 0;
const UI_THROTTLE_MS = 150;
let _processing = false;
function processMeasurementPayload(
payload: Uint8Array,
devStore: ReturnType<typeof useDeviceStore.getState>,
dataStore: ReturnType<typeof useDataStore.getState>,
) {
// Discard frames that arrive after we've already stopped — these are
// in-flight packets from the device before it processes the STOP command.
if (devStore.deviceStatus === 'idle') return;
if (_processing) return;
_processing = true;
const meta = parseMetadata(payload);
if (!meta) return;
try {
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 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,
sampleFreqCode: cfg.sampleFreq,
timestamp: Date.now(),
frameId: dataStore.nextFrameId(),
};
const measurementFrame: MeasurementFrame = {
meta,
adcRaw,
adcUV,
accNum,
gain: AMP_GAIN[cfg.ampRatio] ?? 1,
sampleFreqCode: cfg.sampleFreq,
timestamp: Date.now(),
frameId: dataStore.nextFrameId(),
};
dataStore.addFrame(measurementFrame);
void StorageService.persistFrame(measurementFrame, dataStore.sessionId);
devStore.updateTelemetry({
batteryVolt: meta.batteryVolt,
temperature: meta.temperature,
gpsStatus: meta.gpsStatus,
sdStatus: meta.sdStatus,
frameId: measurementFrame.frameId,
});
const isSingle = devStore.deviceStatus === 'single';
const now = Date.now();
if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) {
_lastUIUpdateTime = now;
dataStore.addFrameUI(measurementFrame);
}
// Single acquisition completes as soon as the data frame arrives —
// automatically send STOP so the device returns to idle.
if (devStore.deviceStatus === 'single') {
void deviceStop();
devStore.updateTelemetry({
batteryVolt: meta.batteryVolt,
temperature: meta.temperature,
gpsStatus: meta.gpsStatus,
sdStatus: meta.sdStatus,
frameId: measurementFrame.frameId,
});
if (isSingle) {
void deviceStop();
}
} finally {
_processing = false;
}
}
@@ -138,13 +153,23 @@ function processMeasurementPayload(
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
return new Promise((resolve, reject) => {
if (__DEV__) console.log(`[Device] sendAndWaitAck: waiting for ack func=0x${ackFunc.toString(16)}, timeout=${timeoutMs}ms`);
const timer = setTimeout(() => {
pendingAcks.delete(ackFunc);
if (__DEV__) console.warn(`[Device] ACK timeout for func=0x${ackFunc.toString(16)}, pendingAcks remaining:`, [...pendingAcks.keys()].map(k => '0x' + k.toString(16)));
reject(new Error('ACK timeout'));
}, timeoutMs);
const existing = pendingAcks.get(ackFunc);
if (existing) {
pendingAcks.delete(ackFunc);
existing({ result: 0x02, reason: 'superseded' });
}
pendingAcks.set(ackFunc, (ack) => {
clearTimeout(timer);
if (__DEV__) console.log(`[Device] ACK received for func=0x${ackFunc.toString(16)}, result=0x${ack.result.toString(16)}`);
resolve(ack);
});
@@ -171,7 +196,15 @@ export async function deviceStartSingle(): Promise<AckPacket> {
}
export async function deviceStop(): Promise<AckPacket> {
return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK);
return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK, 2000);
}
export function flushPendingData() {
tcpService.resetParser();
splitChunks = [];
splitActive = false;
splitFrameNo = 0;
_processing = false;
}
// Initiate split frame transfer to request large data
+69 -5
View File
@@ -1,6 +1,7 @@
import * as SQLite from 'expo-sqlite';
import * as FileSystem from 'expo-file-system/legacy';
import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types';
import { AMP_GAIN } from '../protocol/constants';
import * as BinLoader from './BinLoader';
import * as TemBundle from './TemBundle';
@@ -70,10 +71,37 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
`);
await runMigrations(_db);
}
return _db;
}
async function runMigrations(db: SQLite.SQLiteDatabase) {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
let version = row?.user_version ?? 0;
if (version < 1) {
try { await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT'); } catch {}
await db.runAsync('PRAGMA user_version = 1');
version = 1;
}
if (version < 2) {
const orphans = await db.getAllAsync<{ session_id: string }>(
'SELECT session_id FROM sessions WHERE project_id IS NULL',
);
if (orphans.length > 0) {
const defId = generateProjectId();
await db.runAsync(
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
[defId, '默认工程', Date.now()],
);
await db.runAsync('UPDATE sessions SET project_id = ? WHERE project_id IS NULL', [defId]);
}
await db.runAsync('PRAGMA user_version = 2');
version = 2;
}
}
async function ensureDataDir() {
const info = await FileSystem.getInfoAsync(DATA_DIR);
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
@@ -84,23 +112,44 @@ export async function initStorage(): Promise<void> {
await ensureDataDir();
}
export async function hasAnyProject(): Promise<boolean> {
const d = await getDb();
const row = await d.getFirstAsync<{ cnt: number }>('SELECT COUNT(*) AS cnt FROM projects');
return (row?.cnt ?? 0) > 0;
}
export async function getFirstProject(): Promise<ProjectInfo | null> {
const d = await getDb();
const r = await d.getFirstAsync<{ project_id: string; name: string; created_at: number }>(
'SELECT project_id, name, created_at FROM projects ORDER BY created_at DESC LIMIT 1',
);
if (!r) return null;
return { projectId: r.project_id, name: r.name, createdAt: r.created_at, lineCount: 0 };
}
// ── Project CRUD ────────────────────────────────────────────────────────────
function generateProjectId(): string {
const now = new Date();
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
const rand = Math.random().toString(36).slice(2, 6);
return (
`P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` +
`${pad(now.getMilliseconds(), 3)}_${rand}`
);
}
function sanitizeName(name: string): string {
return name.trim().replace(/[\/\\:*?"<>|.\x00]/g, '_').slice(0, 50);
}
export async function createProject(name: string): Promise<string> {
const d = await getDb();
const id = generateProjectId();
await d.runAsync(
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
[id, name.trim(), Date.now()],
[id, sanitizeName(name), Date.now()],
);
return id;
}
@@ -132,6 +181,19 @@ export async function renameProject(projectId: string, name: string): Promise<vo
export async function deleteProject(projectId: string): Promise<void> {
const d = await getDb();
const sessions = await d.getAllAsync<{ session_id: string }>(
'SELECT session_id FROM sessions WHERE project_id = ?', [projectId],
);
for (const s of sessions) {
const frames = await d.getAllAsync<{ bin_path: string | null }>(
'SELECT bin_path FROM frames WHERE session_id = ?', [s.session_id],
);
for (const f of frames) {
if (f.bin_path) { try { await FileSystem.deleteAsync(f.bin_path, { idempotent: true }); } catch {} }
}
await d.runAsync('DELETE FROM frames WHERE session_id = ?', [s.session_id]);
}
await d.runAsync('DELETE FROM sessions WHERE project_id = ?', [projectId]);
await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]);
}
@@ -215,7 +277,7 @@ export async function persistFrame(frame: MeasurementFrame, sessionId: string):
} catch { binPath = null; }
await d.runAsync(
`INSERT OR REPLACE INTO frames (
`INSERT OR IGNORE INTO frames (
frame_id, session_id, ts, utc, lon, lat, alt,
ch_num, acc_num, gain, gps_st, sd_st, amp_ratio,
current, temp, batt, roll, pitch, yaw, src_mode, bin_path
@@ -381,7 +443,9 @@ export async function exportProject(
const bundleBytes = await TemBundle.packBundle(manifest, bins);
await ensureDataDir();
const outPath = DATA_DIR + `TEM_${proj.name}_${Date.now()}.tem`;
const safeName = sanitizeName(proj.name);
const ts = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const outPath = DATA_DIR + `TEM_${safeName}_${ts}.tem`;
await BinLoader.writeFileBytes(outPath, bundleBytes);
return outPath;
}
@@ -473,7 +537,7 @@ export async function importProject(
[
frameId, newSessionId, meta.timestamp,
meta.utc, meta.longitude, meta.latitude, meta.altitude,
meta.channelNum, meta.accNum, meta.ampRatio,
meta.channelNum, meta.accNum, AMP_GAIN[meta.ampRatio] ?? 1,
meta.gpsStatus, meta.sdStatus, meta.ampRatio,
meta.current, meta.temperature, meta.batteryVolt,
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,
+68 -7
View File
@@ -15,11 +15,13 @@ class TcpService {
private callbacks: TcpCallbacks | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectEnabled = false;
private host = '';
private port = 0;
connect(host: string, port: number, callbacks: TcpCallbacks) {
this.disconnect();
this.host = host;
this.port = port;
this.callbacks = callbacks;
@@ -34,33 +36,62 @@ class TcpService {
this.socket = null;
}
if (__DEV__) console.log(`[TCP] creating socket → ${this.host}:${this.port}`);
const connectTimeout = setTimeout(() => {
if (!this.connected && sock) {
sock.destroy();
this.callbacks?.onError(new Error('连接超时 (10s)'));
this.scheduleReconnect();
}
}, 10000);
const sock = TcpSocket.createConnection(
{ host: this.host, port: this.port, tls: false },
() => {
clearTimeout(connectTimeout);
this.connected = true;
clearTimeout(this.reconnectTimer!);
this.startHeartbeat();
if (__DEV__) console.log(`[TCP] ✓ connected to ${this.host}:${this.port}`);
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);
if (__DEV__) console.log('[TCP] data event fired, type:', typeof data, 'constructor:', data?.constructor?.name, 'length:', data?.length ?? data?.byteLength ?? '?');
let arr: Uint8Array;
if (data instanceof Uint8Array) {
arr = data;
} else if (data instanceof ArrayBuffer) {
arr = new Uint8Array(data);
} else if (typeof data === 'string') {
// react-native-tcp-socket may deliver data as a UTF-8 string
const bytes = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i) & 0xff;
arr = bytes;
} else if (data?.buffer) {
arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
} else {
if (__DEV__) console.warn('[TCP] unknown data type:', typeof data, data);
return;
}
if (__DEV__) console.log('[TCP] raw recv', arr.length, 'bytes:', Array.from(arr.slice(0, 20)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '));
const frames = this.parser.feed(arr);
if (__DEV__) console.log('[TCP] parsed frames:', frames.length);
frames.forEach((f) => this.callbacks?.onFrame(f));
});
sock.on('error', (err: Error) => {
if (__DEV__) console.warn('[TCP] error event:', err.message);
this.stopHeartbeat();
this.connected = false;
this.callbacks?.onError(err);
this.scheduleReconnect();
});
sock.on('close', () => {
if (__DEV__) console.log('[TCP] close event');
this.stopHeartbeat();
this.connected = false;
this.callbacks?.onClose();
this.scheduleReconnect();
@@ -69,6 +100,29 @@ class TcpService {
this.socket = sock;
}
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.connected && this.socket) {
try {
this.socket.write('0');
} catch {
this.connected = false;
this.stopHeartbeat();
this.callbacks?.onError(new Error('Heartbeat write failed'));
this.scheduleReconnect();
}
}
}, 8000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private scheduleReconnect() {
if (!this.reconnectEnabled) return;
clearTimeout(this.reconnectTimer!);
@@ -83,15 +137,18 @@ class TcpService {
send(data: Uint8Array): boolean {
if (!this.connected || !this.socket) return false;
try {
if (__DEV__) console.log('[TCP] send', data.length, 'bytes:', Array.from(data.slice(0, 16)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '));
this.socket.write(data as unknown as string);
return true;
} catch {
} catch (e) {
if (__DEV__) console.warn('[TCP] send error:', e);
return false;
}
}
disconnect() {
this.reconnectEnabled = false;
this.stopHeartbeat();
clearTimeout(this.reconnectTimer!);
this.socket?.destroy();
this.socket = null;
@@ -102,6 +159,10 @@ class TcpService {
isConnected() {
return this.connected;
}
resetParser() {
this.parser.reset();
}
}
// Singleton instance shared across the app
+58 -19
View File
@@ -4,9 +4,10 @@ import type { MeasurementFrame } from '../protocol/types';
import * as StorageService from '../services/StorageService';
import { fileStorage } from '../utils/storage';
const MAX_HISTORY = 500;
const MAX_HISTORY = 50;
interface DataState {
hasProject: boolean;
currentFrame: MeasurementFrame | null;
history: MeasurementFrame[];
sessionId: string;
@@ -14,24 +15,23 @@ interface DataState {
_frameCounter: number;
nextFrameId: () => number;
addFrame: (frame: MeasurementFrame) => void;
addFrameUI: (frame: MeasurementFrame) => void;
deleteFrame: (frameId: number) => void;
clearHistory: () => void;
/** Start a new line (session), optionally under a project. */
newSession: (projectId?: string | null) => Promise<void>;
/** Resume an existing session from a previous run. In-memory history starts
* empty; the frame counter resumes from the DB max so IDs don't collide. */
newSession: (projectId: string) => Promise<void>;
resumeSession: (sessionId: string, projectId?: string | null) => Promise<void>;
/** Change the project association of the current session. */
setProject: (projectId: string | null) => Promise<void>;
resetToNoProject: () => void;
init: () => Promise<void>;
}
export const useDataStore = create<DataState>()(
persist(
(set, get) => ({
hasProject: false,
currentFrame: null,
history: [],
sessionId: generateSessionId(),
sessionId: '',
projectId: null,
_frameCounter: 0,
@@ -49,6 +49,13 @@ export const useDataStore = create<DataState>()(
void StorageService.persistFrame(frame, get().sessionId);
},
addFrameUI: (frame) => {
set((s) => ({
currentFrame: frame,
history: [frame, ...s.history].slice(0, MAX_HISTORY),
}));
},
deleteFrame: (frameId) => {
const { sessionId, history, currentFrame } = get();
void StorageService.deleteFrame(frameId, sessionId);
@@ -61,25 +68,23 @@ export const useDataStore = create<DataState>()(
clearHistory: () => set({ history: [], currentFrame: null }),
newSession: async (projectId) => {
newSession: async (projectId: string) => {
const id = generateSessionId();
const pid = projectId !== undefined ? projectId : get().projectId;
await StorageService.ensureSession(id, pid);
set({ sessionId: id, projectId: pid, history: [], currentFrame: null, _frameCounter: 0 });
await StorageService.ensureSession(id, projectId);
set({ sessionId: id, projectId, history: [], currentFrame: null, _frameCounter: 0, hasProject: true });
},
resumeSession: async (sessionId, projectId) => {
const pid = projectId !== undefined ? projectId : get().projectId;
await StorageService.ensureSession(sessionId, pid);
const maxId = await StorageService.getMaxFrameId(sessionId);
// In-memory history intentionally starts empty — past frames are in SQLite
// and visible in the Profile / SessionHistory sections.
set({
sessionId,
projectId: pid ?? null,
history: [],
currentFrame: null,
_frameCounter: maxId + 1,
hasProject: true,
});
},
@@ -89,19 +94,51 @@ export const useDataStore = create<DataState>()(
set({ projectId });
},
resetToNoProject: () => set({
hasProject: false,
projectId: null,
sessionId: '',
history: [],
currentFrame: null,
_frameCounter: 0,
}),
init: async () => {
await StorageService.initStorage();
const exists = await StorageService.hasAnyProject();
if (!exists) {
set({ hasProject: false });
return;
}
set({ hasProject: true });
const { sessionId, projectId } = get();
await StorageService.ensureSession(sessionId, projectId);
const maxId = await StorageService.getMaxFrameId(sessionId);
set({ _frameCounter: maxId + 1 });
if (projectId && sessionId) {
const projects = await StorageService.listProjects();
if (projects.some((p) => p.projectId === projectId)) {
await StorageService.ensureSession(sessionId, projectId);
const maxId = await StorageService.getMaxFrameId(sessionId);
set({ _frameCounter: maxId + 1 });
return;
}
}
const first = await StorageService.getFirstProject();
if (first) {
const sessions = await StorageService.listSessionsByProject(first.projectId);
if (sessions.length > 0) {
await get().resumeSession(sessions[0].sessionId, first.projectId);
} else {
await get().newSession(first.projectId);
}
}
},
}),
{
name: 'data-session',
storage: createJSONStorage(() => fileStorage),
// Only persist identity fields. Runtime data (frames, history) is always
// rebuilt from SQLite on demand.
partialize: (state) => ({
sessionId: state.sessionId,
projectId: state.projectId,
@@ -113,8 +150,10 @@ export const useDataStore = create<DataState>()(
function generateSessionId(): string {
const now = new Date();
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
const rand = Math.random().toString(36).slice(2, 6);
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
`_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` +
`${pad(now.getMilliseconds(), 3)}_${rand}`
);
}
+8 -8
View File
@@ -13,20 +13,20 @@ const DEFAULT_GATE_CONFIG: GateConfig = {
const DEFAULT_CONFIG: SetupConfig = {
channelNum: 3,
sendFreq: 0x03, // 4 Hz
sampleFreq: 0x03, // 31.25 kHz
sampleDepth: 1024,
accNum: 50,
sendFreq: 0x05, // 16 Hz
sampleFreq: 0x00, // 250 kHz
sampleDepth: 2000,
accNum: 32,
negAcc456: false,
negAcc123: false,
ampRatio: 0x03, // 1×
dataChannel: DataChannel.WIFI,
compRes: 0,
secondSampleFreq: 0x03,
compDisableDelay: 300, // 300 × 50μs = 15ms
compRes: 12,
secondSampleFreq: 0x00,
compDisableDelay: 60,
sourceMode: 0x00, // single source A
batteryVoltageMin: 0,
filePrefix: 'TEM',
filePrefix: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
};
interface DeviceState {
+57 -4
View File
@@ -91,13 +91,66 @@ export async function saveFrameBin(frame: MeasurementFrame, sessionId: string):
return path;
}
export async function shareFile(filePath: string) {
const canShare = await Sharing.isAvailableAsync();
if (canShare) {
await Sharing.shareAsync(filePath);
function getMime(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase();
const map: Record<string, string> = {
csv: 'text/csv', tem: 'application/octet-stream',
bin: 'application/octet-stream', json: 'application/json',
};
return map[ext ?? ''] ?? 'application/octet-stream';
}
function getFileName(filePath: string): string {
return filePath.split('/').pop() ?? 'export';
}
async function saveToFile(filePath: string): Promise<boolean> {
try {
const perms = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!perms.granted) return false;
const fileName = getFileName(filePath);
const mime = getMime(filePath);
const destUri = await FileSystem.StorageAccessFramework.createFileAsync(
perms.directoryUri, fileName, mime,
);
const content = await FileSystem.readAsStringAsync(filePath, {
encoding: FileSystem.EncodingType.Base64,
});
await FileSystem.writeAsStringAsync(destUri, content, {
encoding: FileSystem.EncodingType.Base64,
});
return true;
} catch {
return false;
}
}
export async function shareFile(filePath: string) {
const { Alert } = require('react-native');
const canShare = await Sharing.isAvailableAsync();
Alert.alert('导出方式', getFileName(filePath), [
canShare ? {
text: '分享',
onPress: () => Sharing.shareAsync(filePath, {
mimeType: getMime(filePath),
dialogTitle: '导出数据',
}),
} : null,
{
text: '保存到文件',
onPress: async () => {
const ok = await saveToFile(filePath);
if (ok) Alert.alert('保存成功', '文件已保存到所选目录');
else Alert.alert('保存取消', '未选择保存位置或保存失败');
},
},
{ text: '取消', style: 'cancel' },
].filter(Boolean));
}
export async function listSessionFiles(sessionId: string): Promise<string[]> {
await ensureDir();
const all = await FileSystem.readDirectoryAsync(DATA_DIR);