基本功能完成
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user