v0.1
This commit is contained in:
@@ -10,35 +10,42 @@ interface Props {
|
||||
|
||||
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{Array.from({ length: maxChannels }, (_, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.chip, { borderColor: CHANNEL_COLORS[i], opacity: visible[i] ? 1 : 0.35 }]}
|
||||
onPress={() => onToggle(i)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
||||
<Text style={[styles.label, { color: visible[i] ? CHANNEL_COLORS[i] : '#888' }]}>
|
||||
CH{i + 1}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={S.row}>
|
||||
{Array.from({ length: maxChannels }, (_, i) => {
|
||||
const color = CHANNEL_COLORS[i];
|
||||
const active = visible[i];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[
|
||||
S.chip,
|
||||
{ borderColor: active ? color + '88' : '#1e1e30' },
|
||||
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>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
|
||||
const S = StyleSheet.create({
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingVertical: 10 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
gap: 4,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
backgroundColor: '#0c0c18',
|
||||
},
|
||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||
label: { fontSize: 11, fontWeight: '600' },
|
||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||
label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { View, Text, StyleSheet, Platform } from 'react-native';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
import { formatBattery, formatTemperature, formatCoord } from '../utils/format';
|
||||
import { formatBattery, formatTemperature } from '../utils/format';
|
||||
|
||||
function Indicator({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||
function Chip({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<View style={styles.indicator}>
|
||||
<View style={[styles.dot, { backgroundColor: ok === false ? '#ff4444' : ok ? '#44cc44' : '#888888' }]} />
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<View style={[S.chip, 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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -19,55 +19,68 @@ export function DeviceStatusBar() {
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const meta = frame?.meta;
|
||||
|
||||
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
|
||||
const lat = meta?.latitude ?? 0;
|
||||
const lon = meta?.longitude ?? 0;
|
||||
const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
|
||||
const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
|
||||
const coordStr = hasGps && lat ? `${lat.toFixed(4)}, ${lon.toFixed(4)}` : '--';
|
||||
|
||||
const running = deviceStatus === 'running';
|
||||
const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
|
||||
const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
|
||||
const single = deviceStatus === 'single';
|
||||
const statusColor = running ? '#3ddc84' : single ? '#ffb84d' : '#3a3a5a';
|
||||
const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止';
|
||||
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<View style={[styles.statusBadge, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
||||
<View style={S.bar}>
|
||||
<View style={[S.statusPill, { borderColor: statusColor + '55', backgroundColor: statusColor + '18' }]}>
|
||||
<Text style={[S.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
||||
</View>
|
||||
|
||||
<Indicator label="GPS" value={gpsStr} ok={hasGps ? true : false} />
|
||||
<Indicator label="SD" value={sdStatus > 0 ? 'OK' : '--'} ok={sdStatus > 0} />
|
||||
<Indicator label="电池" value={batteryVolt ? formatBattery(batteryVolt) : '--'} />
|
||||
<Indicator label="温度" value={temperature ? formatTemperature(temperature) : '--'} />
|
||||
<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)} />}
|
||||
{frame && (
|
||||
<View style={S.frameCount}>
|
||||
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const S = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#111',
|
||||
backgroundColor: '#0c0c18',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
paddingVertical: 8,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
gap: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333',
|
||||
borderBottomColor: '#1a1a2a',
|
||||
},
|
||||
statusBadge: {
|
||||
statusPill: {
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
statusText: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
gap: 4,
|
||||
backgroundColor: '#111120',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#22223a',
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
statusDot: { width: 6, height: 6, borderRadius: 3 },
|
||||
statusText: { fontSize: 11, fontWeight: '600' },
|
||||
indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||
label: { color: '#888', fontSize: 10 },
|
||||
value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
|
||||
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' },
|
||||
frameCount: { marginLeft: 'auto' },
|
||||
frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
});
|
||||
|
||||
+169
-138
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import React, { useState, useEffect } 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 { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
|
||||
|
||||
interface PickerRowProps {
|
||||
label: string;
|
||||
@@ -17,20 +12,22 @@ interface PickerRowProps {
|
||||
|
||||
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.optScroll}>
|
||||
{options.map((o) => (
|
||||
<TouchableOpacity
|
||||
key={o.code}
|
||||
style={[styles.optChip, o.code === value && styles.optChipActive]}
|
||||
onPress={() => onChange(o.code)}
|
||||
>
|
||||
<Text style={[styles.optText, o.code === value && styles.optTextActive]}>
|
||||
{o.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={S.row}>
|
||||
<Text style={S.rowLabel}>{label}</Text>
|
||||
<ScrollView 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]}
|
||||
onPress={() => onChange(o.code)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.optText, active && S.optTextActive]}>{o.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -39,34 +36,63 @@ function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
||||
interface NumberRowProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChangeText: (v: string) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
onCommit: (v: number) => void;
|
||||
suffix?: string;
|
||||
keyboardType?: 'numeric';
|
||||
}
|
||||
|
||||
function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
|
||||
// Keeps a local draft string while typing; commits a clamped integer on blur.
|
||||
function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
// Sync when the store value changes externally (e.g. presets).
|
||||
useEffect(() => { setDraft(String(value)); }, [value]);
|
||||
|
||||
const commit = () => {
|
||||
const n = parseInt(draft, 10);
|
||||
const clamped = isNaN(n) ? min : Math.max(min, Math.min(max, n));
|
||||
setDraft(String(clamped));
|
||||
if (clamped !== value) onCommit(clamped);
|
||||
};
|
||||
|
||||
const invalid = (() => {
|
||||
const n = parseInt(draft, 10);
|
||||
return isNaN(n) || n < min || n > max;
|
||||
})();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<View style={styles.inputWrap}>
|
||||
<View style={S.row}>
|
||||
<Text style={S.rowLabel}>{label}</Text>
|
||||
<View style={S.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(value)}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType={keyboardType}
|
||||
style={[S.input, invalid && S.inputInvalid]}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
onBlur={commit}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
placeholderTextColor="#555"
|
||||
placeholderTextColor="#3a3a5a"
|
||||
/>
|
||||
{suffix && <Text style={styles.suffix}>{suffix}</Text>}
|
||||
{suffix && <Text style={S.suffix}>{suffix}</Text>}
|
||||
</View>
|
||||
{invalid && (
|
||||
<Text style={S.hint}>{min}–{max}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
onAnyChange?: () => void;
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<View style={S.sectionHeader}>
|
||||
<Text style={S.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormProps { onAnyChange?: () => void }
|
||||
|
||||
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
||||
const { config, updateConfig } = useDeviceStore();
|
||||
const patch = (partial: Partial<typeof config>) => {
|
||||
@@ -75,161 +101,166 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Channel selector */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>通道数</Text>
|
||||
<View style={styles.channelRow}>
|
||||
{[1, 2, 3, 4, 5, 6].map((n) => (
|
||||
<TouchableOpacity
|
||||
key={n}
|
||||
style={[styles.chChip, config.channelNum === n && styles.chChipActive]}
|
||||
onPress={() => patch({ channelNum: n })}
|
||||
>
|
||||
<Text style={[styles.chText, config.channelNum === n && styles.chTextActive]}>
|
||||
{n}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={S.container}>
|
||||
|
||||
<SectionHeader title="采集配置" />
|
||||
|
||||
{/* Channel count */}
|
||||
<View style={S.row}>
|
||||
<Text style={S.rowLabel}>通道数</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]}
|
||||
onPress={() => patch({ channelNum: n })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.chText, active && S.chTextActive]}>{n}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<PickerRow
|
||||
label="发射频率"
|
||||
options={SEND_FREQ_TABLE}
|
||||
value={config.sendFreq}
|
||||
onChange={(v) => patch({ sendFreq: v })}
|
||||
/>
|
||||
|
||||
<PickerRow
|
||||
label="采样频率"
|
||||
options={SAMPLE_FREQ_TABLE}
|
||||
value={config.sampleFreq}
|
||||
onChange={(v) => patch({ sampleFreq: v })}
|
||||
/>
|
||||
<PickerRow label="发射频率" options={SEND_FREQ_TABLE} value={config.sendFreq} onChange={(v) => patch({ sendFreq: v })} />
|
||||
<PickerRow label="采样频率" options={SAMPLE_FREQ_TABLE} value={config.sampleFreq} onChange={(v) => patch({ sampleFreq: v })} />
|
||||
|
||||
<NumberRow
|
||||
label="采样点数"
|
||||
value={config.sampleDepth}
|
||||
onChangeText={(v) => patch({ sampleDepth: parseInt(v, 10) || 0 })}
|
||||
label="采样点数" value={config.sampleDepth}
|
||||
min={64} max={65535}
|
||||
onCommit={(v) => { patch({ sampleDepth: v }); }}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="叠加次数"
|
||||
value={config.accNum}
|
||||
onChangeText={(v) => patch({ accNum: parseInt(v, 10) || 0 })}
|
||||
label="叠加次数" value={config.accNum}
|
||||
min={1} max={9999}
|
||||
onCommit={(v) => { patch({ accNum: v }); }}
|
||||
/>
|
||||
|
||||
<PickerRow
|
||||
label="增益"
|
||||
options={AMP_RATIO_TABLE}
|
||||
value={config.ampRatio}
|
||||
onChange={(v) => patch({ ampRatio: v })}
|
||||
/>
|
||||
<SectionHeader title="放大与源" />
|
||||
|
||||
<PickerRow
|
||||
label="源模式"
|
||||
options={SOURCE_MODE_TABLE}
|
||||
value={config.sourceMode}
|
||||
onChange={(v) => patch({ sourceMode: v })}
|
||||
/>
|
||||
<PickerRow label="增 益" options={AMP_RATIO_TABLE} value={config.ampRatio} onChange={(v) => patch({ ampRatio: v })} />
|
||||
<PickerRow label="源模式" options={SOURCE_MODE_TABLE} value={config.sourceMode} onChange={(v) => patch({ sourceMode: v })} />
|
||||
|
||||
{/* Reverse accumulation flags */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>反向叠加</Text>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>CH1-3</Text>
|
||||
<SectionHeader title="高级设置" />
|
||||
|
||||
{/* Reverse accumulation */}
|
||||
<View style={S.row}>
|
||||
<Text style={S.rowLabel}>反向叠加</Text>
|
||||
<View style={S.switchRow}>
|
||||
<Text style={S.switchLabel}>CH1-3</Text>
|
||||
<Switch
|
||||
value={config.negAcc123}
|
||||
onValueChange={(v) => patch({ negAcc123: v })}
|
||||
thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
|
||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
||||
thumbColor={config.negAcc123 ? '#4a9eff' : '#444'}
|
||||
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
||||
/>
|
||||
<Text style={styles.switchLabel}>CH4-6</Text>
|
||||
<Text style={S.switchLabel}>CH4-6</Text>
|
||||
<Switch
|
||||
value={config.negAcc456}
|
||||
onValueChange={(v) => patch({ negAcc456: v })}
|
||||
thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
|
||||
trackColor={{ false: '#333', true: '#1a4a88' }}
|
||||
thumbColor={config.negAcc456 ? '#4a9eff' : '#444'}
|
||||
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<NumberRow
|
||||
label="补偿延时"
|
||||
value={config.compDisableDelay}
|
||||
onChangeText={(v) => patch({ compDisableDelay: parseInt(v, 10) || 0 })}
|
||||
label="补偿延时" value={config.compDisableDelay}
|
||||
min={0} max={65535}
|
||||
onCommit={(v) => { patch({ compDisableDelay: v }); }}
|
||||
suffix="×50μs"
|
||||
/>
|
||||
|
||||
{/* File prefix */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>文件前缀</Text>
|
||||
<View style={styles.inputWrap}>
|
||||
<View style={S.row}>
|
||||
<Text style={S.rowLabel}>文件前缀</Text>
|
||||
<View style={S.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={S.input}
|
||||
value={config.filePrefix}
|
||||
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
||||
maxLength={15}
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#555"
|
||||
placeholderTextColor="#3a3a5a"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#111' },
|
||||
const S = StyleSheet.create({
|
||||
container: { backgroundColor: '#090912' },
|
||||
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#3a3a5a',
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#2a2a2a',
|
||||
gap: 10,
|
||||
borderBottomColor: '#141422',
|
||||
gap: 12,
|
||||
backgroundColor: '#0e0e1c',
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
|
||||
optScroll: { flex: 1 },
|
||||
optChip: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
marginRight: 6,
|
||||
},
|
||||
optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
||||
optText: { color: '#888', fontSize: 11 },
|
||||
optTextActive: { color: '#4a9eff', fontWeight: '600' },
|
||||
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||
rowLabel: { color: '#4a4a6a', 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' },
|
||||
|
||||
// Number input
|
||||
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#eee',
|
||||
color: '#c0c0d8',
|
||||
fontSize: 13,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||
paddingVertical: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1e1e30',
|
||||
},
|
||||
suffix: { color: '#666', fontSize: 11, marginLeft: 6 },
|
||||
inputInvalid: { borderBottomColor: '#ff5c6e' },
|
||||
suffix: { color: '#3a3a5a', fontSize: 10, marginLeft: 8 },
|
||||
hint: { color: '#ff5c6e', fontSize: 9, marginLeft: 4 },
|
||||
|
||||
// Channel chips
|
||||
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
|
||||
chChip: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
borderColor: '#1e1e30',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0c0c18',
|
||||
},
|
||||
chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
|
||||
chText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
||||
chTextActive: { color: '#4a9eff' },
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
|
||||
switchLabel: { color: '#888', fontSize: 12 },
|
||||
chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' },
|
||||
chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' },
|
||||
chTextActive: { color: '#4a9eff' },
|
||||
|
||||
// Switch row
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
|
||||
switchLabel: { color: '#4a4a6a', fontSize: 12 },
|
||||
});
|
||||
|
||||
@@ -5,149 +5,221 @@ import { CHANNEL_COLORS } from '../protocol/constants';
|
||||
import type { WaveformData } from '../hooks/useWaveform';
|
||||
|
||||
interface Props {
|
||||
data: WaveformData;
|
||||
data: WaveformData;
|
||||
visibleChannels: boolean[];
|
||||
width: number;
|
||||
height: number;
|
||||
width: number;
|
||||
height: number;
|
||||
logScale?: boolean; // default true
|
||||
}
|
||||
|
||||
const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
|
||||
const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
|
||||
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;
|
||||
|
||||
export function WaveformChart({ data, visibleChannels, width, height }: Props) {
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = height - PADDING.top - PADDING.bottom;
|
||||
// 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)));
|
||||
for (const f of [1, 2, 2.5, 5, 10]) {
|
||||
if (f * mag >= rough) return f * mag;
|
||||
}
|
||||
return 10 * mag;
|
||||
}
|
||||
|
||||
const yMin = data.minLogUV - 0.5;
|
||||
const yMax = data.maxLogUV + 0.5;
|
||||
const yRange = yMax - yMin;
|
||||
function xTicks(totalMs: number): number[] {
|
||||
if (totalMs <= 0) return [];
|
||||
const step = niceStep(totalMs, X_TICK_COUNT);
|
||||
const ticks: number[] = [];
|
||||
for (let t = step; t < totalMs * 0.99; t += step) ticks.push(t);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// Map log10(absVal) → canvas y (top=high, bottom=low)
|
||||
const toY = (uv: number): number => {
|
||||
const absV = Math.abs(uv);
|
||||
if (absV < 1e-12) return PADDING.top + plotH;
|
||||
const logV = Math.log10(absV);
|
||||
return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
|
||||
};
|
||||
function linYTicks(minUV: number, maxUV: number): number[] {
|
||||
const range = maxUV - minUV;
|
||||
const step = niceStep(range, 6);
|
||||
const start = Math.ceil(minUV / step) * step;
|
||||
const ticks: number[] = [];
|
||||
for (let v = start; v <= maxUV * 1.001; v += step) ticks.push(v);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// Map sample index → canvas x
|
||||
function formatUVShort(uv: number): string {
|
||||
const abs = Math.abs(uv);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e6) return `${(uv / 1e6).toFixed(1)}V`;
|
||||
if (abs >= 1e3) return `${(uv / 1e3).toFixed(1)}mV`;
|
||||
if (abs >= 1) return `${uv.toFixed(0)}μV`;
|
||||
if (abs >= 1e-3) return `${(uv * 1e3).toFixed(1)}nV`;
|
||||
return uv.toExponential(1);
|
||||
}
|
||||
|
||||
function formatLogTick(v: number): string {
|
||||
if (v >= 1000) return `${v / 1000}k`;
|
||||
return `${v}`;
|
||||
}
|
||||
|
||||
function formatMsShort(ms: number): string {
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms >= 1) return `${ms % 1 === 0 ? ms : ms.toFixed(1)}ms`;
|
||||
return `${(ms * 1000).toFixed(0)}μs`;
|
||||
}
|
||||
|
||||
export function WaveformChart({ data, visibleChannels, width, height, logScale = true }: Props) {
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = height - PADDING.top - PADDING.bottom;
|
||||
|
||||
// ── 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 yLinRange = Math.max(yLinMax - yLinMin, 1e-9);
|
||||
|
||||
const toY = useMemo(() => {
|
||||
if (logScale) {
|
||||
return (uv: number): number => {
|
||||
const absV = Math.abs(uv);
|
||||
if (absV < 1e-12) return PADDING.top + plotH;
|
||||
const logV = Math.log10(absV);
|
||||
return PADDING.top + plotH - ((logV - yLogMin) / yLogRange) * plotH;
|
||||
};
|
||||
}
|
||||
return (uv: number): number =>
|
||||
PADDING.top + plotH - ((uv - yLinMin) / yLinRange) * plotH;
|
||||
}, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]);
|
||||
|
||||
// ── X mapping ────────────────────────────────────────────────────────────
|
||||
const toX = (i: number, total: number): number =>
|
||||
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
|
||||
|
||||
// Y-axis grid lines and labels
|
||||
const yGridLines = useMemo(() => {
|
||||
return Y_TICKS.filter((v) => {
|
||||
const log = Math.log10(v);
|
||||
return log >= yMin && log <= yMax;
|
||||
});
|
||||
}, [yMin, yMax]);
|
||||
const toXms = (ms: number): number =>
|
||||
PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW;
|
||||
|
||||
// Channel paths
|
||||
// ── Grid / tick values ──────────────────────────────────────────────────
|
||||
const yGridValues = useMemo(() => {
|
||||
if (logScale) {
|
||||
return LOG_Y_TICKS.filter(v => {
|
||||
const log = Math.log10(v);
|
||||
return log >= yLogMin && log <= yLogMax;
|
||||
});
|
||||
}
|
||||
return linYTicks(yLinMin, yLinMax);
|
||||
}, [logScale, yLogMin, yLogMax, yLinMin, yLinMax]);
|
||||
|
||||
const xTickValues = useMemo(() => xTicks(data.totalTimeMs), [data.totalTimeMs]);
|
||||
|
||||
// ── Channel paths ────────────────────────────────────────────────────────
|
||||
const channelPaths = useMemo(() => {
|
||||
return data.channels.map((ch, idx) => {
|
||||
if (!visibleChannels[idx]) return null;
|
||||
const path = Skia.Path.Make();
|
||||
let moved = false;
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
if (logScale && Math.abs(ch[i]) < 1e-12) continue;
|
||||
const x = toX(i, ch.length);
|
||||
const y = toY(ch[i]);
|
||||
if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
|
||||
if (!moved) {
|
||||
path.moveTo(x, y);
|
||||
moved = true;
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
if (!isFinite(y)) continue;
|
||||
if (!moved) { path.moveTo(x, y); moved = true; }
|
||||
else path.lineTo(x, y);
|
||||
}
|
||||
return path;
|
||||
});
|
||||
}, [data, visibleChannels, plotW, plotH, yMin, yRange]);
|
||||
}, [data, visibleChannels, toY, plotW, plotH]);
|
||||
|
||||
const top = PADDING.top;
|
||||
const bot = PADDING.top + plotH;
|
||||
const left = PADDING.left;
|
||||
const right = PADDING.left + plotW;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width, height }]}>
|
||||
<View style={[S.container, { width, height }]}>
|
||||
<Canvas style={{ width, height }}>
|
||||
{/* Background */}
|
||||
|
||||
{/* Y-axis grid lines */}
|
||||
{yGridLines.map((v) => {
|
||||
{/* 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(PADDING.left, y)}
|
||||
p2={vec(PADDING.left + plotW, y)}
|
||||
color="#333333"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
|
||||
color="#333333" strokeWidth={0.5} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis baseline */}
|
||||
<Line
|
||||
p1={vec(PADDING.left, PADDING.top + plotH)}
|
||||
p2={vec(PADDING.left + plotW, PADDING.top + plotH)}
|
||||
color="#666666"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* X grid lines */}
|
||||
{xTickValues.map((ms) => {
|
||||
const x = toXms(ms);
|
||||
return (
|
||||
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
|
||||
color="#2a2a2a" strokeWidth={0.5} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Channel waveform paths */}
|
||||
{/* Baseline */}
|
||||
{!logScale && (
|
||||
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))}
|
||||
color="#555555" strokeWidth={0.8} />
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
<Path key={idx} path={path}
|
||||
color={CHANNEL_COLORS[idx]}
|
||||
style="stroke"
|
||||
strokeWidth={1.5}
|
||||
strokeJoin="round"
|
||||
strokeCap="round"
|
||||
/>
|
||||
style="stroke" strokeWidth={1.5}
|
||||
strokeJoin="round" strokeCap="round" />
|
||||
);
|
||||
})}
|
||||
</Canvas>
|
||||
|
||||
{/* Y-axis labels (React Native text overlay, positioned absolutely) */}
|
||||
{yGridLines.map((v) => {
|
||||
{/* Y-axis labels */}
|
||||
{yGridValues.map((v) => {
|
||||
const y = toY(v);
|
||||
const label = v >= 1000 ? `${v / 1000}k` : v >= 1 ? `${v}` : `${v}`;
|
||||
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||
const label = logScale ? formatLogTick(v) : formatUVShort(v);
|
||||
return (
|
||||
<Text
|
||||
key={v}
|
||||
style={[styles.axisLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}
|
||||
>
|
||||
<Text key={v}
|
||||
style={[S.yLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<Text style={[styles.xLabel, { top: height - 18, left: PADDING.left }]}>
|
||||
时间 →
|
||||
{/* 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>
|
||||
|
||||
{/* Y-axis unit */}
|
||||
<Text style={[styles.yUnit, { top: 0, left: 0 }]}>μV</Text>
|
||||
{/* Scale mode badge */}
|
||||
<Text style={[S.scaleBadge, { top: 2, right: 4 }]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { backgroundColor: '#1a1a1a', position: 'relative' },
|
||||
axisLabel: {
|
||||
position: 'absolute',
|
||||
color: '#aaaaaa',
|
||||
fontSize: 9,
|
||||
textAlign: 'right',
|
||||
},
|
||||
xLabel: {
|
||||
position: 'absolute',
|
||||
color: '#aaaaaa',
|
||||
fontSize: 9,
|
||||
},
|
||||
yUnit: {
|
||||
position: 'absolute',
|
||||
color: '#888888',
|
||||
fontSize: 9,
|
||||
},
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
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';
|
||||
|
||||
function Pill({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
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>
|
||||
);
|
||||
if (onPress) return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{inner}</TouchableOpacity>;
|
||||
return inner;
|
||||
}
|
||||
|
||||
export function GlobalStatusBar() {
|
||||
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 connLabel = connected ? '已连接' : status === 'connecting' ? '连接中' : status === 'reconnecting' ? '重连' : '未连接';
|
||||
|
||||
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 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 stateLabel = running ? '● 运行' : single ? '◎ 单次' : '○ 停止';
|
||||
|
||||
return (
|
||||
<View style={S.bar}>
|
||||
{/* Connection — tappable */}
|
||||
<Pill label="TCP" value={connLabel} color={connColor} onPress={showModal} />
|
||||
|
||||
{/* Device run state */}
|
||||
{connected && (
|
||||
<View style={[S.pill, { borderColor: stateColor + '44' }]}>
|
||||
<Text style={[S.stateText, { color: stateColor }]}>{stateLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* GPS */}
|
||||
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? Colors.green.fg : Colors.text.ghost} />
|
||||
|
||||
{/* SD */}
|
||||
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? Colors.blue.fg : Colors.text.ghost} />
|
||||
|
||||
{/* Battery */}
|
||||
{batteryVolt > 0 && (
|
||||
<Pill label="电量" value={formatBattery(batteryVolt)} color={Colors.text.muted} />
|
||||
)}
|
||||
|
||||
{/* Temperature */}
|
||||
{temperature > 0 && (
|
||||
<Pill label="温度" value={formatTemperature(temperature)} color={Colors.text.muted} />
|
||||
)}
|
||||
|
||||
{/* Frame counter — right-aligned */}
|
||||
{frame && (
|
||||
<View style={S.frameCount}>
|
||||
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const S = 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 },
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||
Modal, ScrollView, ActivityIndicator, KeyboardAvoidingView,
|
||||
Platform, Linking,
|
||||
} from 'react-native';
|
||||
import { useConnectionStore } from '../../stores/connectionStore';
|
||||
import { useDevice } from '../../hooks/useDevice';
|
||||
import { Colors, Radius, Spacing } from '../../design/tokens';
|
||||
|
||||
const LOG_MAX = 20;
|
||||
|
||||
export function ConnectModal() {
|
||||
const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
|
||||
useConnectionStore();
|
||||
const { connect, disconnect } = useDevice();
|
||||
|
||||
const [portStr, setPortStr] = useState(String(port));
|
||||
const [logs, setLogs] = useState<string[]>(['就绪']);
|
||||
const prevStatus = useRef(status);
|
||||
|
||||
const addLog = (msg: string) =>
|
||||
setLogs((p) => [`${new Date().toLocaleTimeString('zh-CN', { hour12: false })} ${msg}`, ...p].slice(0, LOG_MAX));
|
||||
|
||||
// Sync portStr when store port changes (e.g. from persisted storage)
|
||||
useEffect(() => { setPortStr(String(port)); }, [port]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === prevStatus.current) return;
|
||||
prevStatus.current = status;
|
||||
if (status === 'connected') {
|
||||
addLog('连接成功 ✓');
|
||||
const t = setTimeout(() => hideModal(), 900);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (status === 'error') addLog(`错误: ${lastError}`);
|
||||
if (status === 'reconnecting') addLog('断线,重连中…');
|
||||
if (status === 'disconnected') addLog('已断开');
|
||||
}, [status, lastError]);
|
||||
|
||||
const handleConnect = () => {
|
||||
const p = parseInt(portStr, 10);
|
||||
if (!host.trim() || isNaN(p)) { addLog('请检查 IP 和端口'); return; }
|
||||
setPort(p);
|
||||
addLog(`连接 ${host}:${p} …`);
|
||||
connect();
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
disconnect();
|
||||
addLog('主动断开');
|
||||
};
|
||||
|
||||
const connecting = status === 'connecting' || status === 'reconnecting';
|
||||
const connected = status === 'connected';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={hideModal}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<TouchableOpacity style={S.backdrop} activeOpacity={1} onPress={hideModal} />
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={S.kav}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={S.sheet}>
|
||||
{/* Handle */}
|
||||
<View style={S.handle} />
|
||||
|
||||
{/* Title */}
|
||||
<Text style={S.title}>连接仪器</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>
|
||||
</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>
|
||||
|
||||
{/* Inputs */}
|
||||
<View style={S.inputCard}>
|
||||
<View style={S.inputRow}>
|
||||
<Text style={S.inputLabel}>IP 地址</Text>
|
||||
<TextInput
|
||||
style={S.input}
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
keyboardType="numeric"
|
||||
placeholder="192.168.4.1"
|
||||
placeholderTextColor={Colors.text.ghost}
|
||||
autoCapitalize="none"
|
||||
editable={!connecting}
|
||||
/>
|
||||
</View>
|
||||
<View style={S.inputDivider} />
|
||||
<View style={S.inputRow}>
|
||||
<Text style={S.inputLabel}>端 口</Text>
|
||||
<TextInput
|
||||
style={S.input}
|
||||
value={portStr}
|
||||
onChangeText={setPortStr}
|
||||
keyboardType="numeric"
|
||||
placeholder="4321"
|
||||
placeholderTextColor={Colors.text.ghost}
|
||||
editable={!connecting}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action */}
|
||||
{!connected ? (
|
||||
<TouchableOpacity
|
||||
style={[S.connectBtn, connecting && S.connectBtnBusy]}
|
||||
onPress={handleConnect}
|
||||
disabled={connecting}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{connecting
|
||||
? <ActivityIndicator color={Colors.blue.fg} />
|
||||
: <Text style={S.connectBtnTxt}>连 接 设 备</Text>}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={S.connectedRow}>
|
||||
<View style={S.connectedLeft}>
|
||||
<View style={S.greenDot} />
|
||||
<Text style={S.connectedTxt}>已连接 {host}:{port}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect}>
|
||||
<Text style={S.disconnectTxt}>断 开</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Log */}
|
||||
<View style={S.logBox}>
|
||||
<Text style={S.logHeader}>● 连接日志</Text>
|
||||
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
|
||||
{logs.map((l, i) => (
|
||||
<Text key={i} style={[S.logLine, i === 0 && S.logLineLatest]}>{l}</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
kav: {
|
||||
flex: 1,
|
||||
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,
|
||||
},
|
||||
|
||||
guideRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
marginBottom: 6,
|
||||
},
|
||||
stepBadge: {
|
||||
width: 20, height: 20, borderRadius: 10,
|
||||
backgroundColor: Colors.blue.bg,
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
borderWidth: 1, borderColor: Colors.blue.border,
|
||||
},
|
||||
stepNum: { color: Colors.blue.fg, fontSize: 10, fontWeight: '700' },
|
||||
stepTxt: { color: Colors.text.muted, fontSize: 12, flex: 1 },
|
||||
settingsBtn: {
|
||||
backgroundColor: Colors.blue.bg, borderRadius: Radius.sm,
|
||||
paddingHorizontal: 8, paddingVertical: 3,
|
||||
borderWidth: 1, borderColor: Colors.blue.border,
|
||||
},
|
||||
settingsTxt: { color: Colors.blue.fg, fontSize: 11, fontWeight: '600' },
|
||||
|
||||
inputCard: {
|
||||
backgroundColor: Colors.bg.raised,
|
||||
borderRadius: Radius.md,
|
||||
borderWidth: 1, borderColor: Colors.bg.border,
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
connectBtnBusy: { borderColor: Colors.blue.border, backgroundColor: Colors.bg.raised },
|
||||
connectBtnTxt: { color: Colors.blue.fg, 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 },
|
||||
connectedTxt: {
|
||||
color: Colors.green.fg, fontWeight: '700', fontSize: 12,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||
},
|
||||
disconnectBtn: {
|
||||
backgroundColor: Colors.red.bg, borderRadius: Radius.md,
|
||||
paddingVertical: 8, paddingHorizontal: 14,
|
||||
borderWidth: 1, borderColor: Colors.red.border,
|
||||
},
|
||||
disconnectTxt: { color: Colors.red.fg, fontSize: 12, fontWeight: '700', letterSpacing: 1 },
|
||||
|
||||
logBox: {
|
||||
backgroundColor: Colors.bg.void,
|
||||
borderRadius: Radius.md,
|
||||
borderWidth: 1, borderColor: Colors.bg.border,
|
||||
overflow: 'hidden',
|
||||
maxHeight: 120,
|
||||
},
|
||||
logHeader: {
|
||||
color: Colors.green.fg, fontSize: 9, fontWeight: '700', letterSpacing: 1.5,
|
||||
paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5,
|
||||
borderBottomWidth: 1, borderBottomColor: Colors.bg.divider,
|
||||
},
|
||||
logScroll: { padding: Spacing.sm },
|
||||
logLine: {
|
||||
color: Colors.text.ghost, fontSize: 10,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||
marginBottom: 2, lineHeight: 15,
|
||||
},
|
||||
logLineLatest: { color: Colors.text.muted },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
export const Colors = {
|
||||
bg: {
|
||||
void: '#050508',
|
||||
base: '#090912',
|
||||
surface: '#0e0e1c',
|
||||
raised: '#111120',
|
||||
border: '#1a1a2a',
|
||||
divider: '#141422',
|
||||
},
|
||||
text: {
|
||||
primary: '#c0c0d8',
|
||||
secondary: '#9090b8',
|
||||
muted: '#4a4a6a',
|
||||
ghost: '#2a2a4a',
|
||||
},
|
||||
green: { bg: '#0d2018', border: '#1a4a28', fg: '#3ddc84' },
|
||||
blue: { bg: '#0d1a30', border: '#1a3a6e', fg: '#4a9eff' },
|
||||
red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' },
|
||||
teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' },
|
||||
amber: { bg: '#1e1a08', border: '#4a3a1a', fg: '#ffd93d' },
|
||||
} as const;
|
||||
|
||||
export const Spacing = {
|
||||
xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32,
|
||||
} as const;
|
||||
|
||||
export const Radius = {
|
||||
sm: 6, md: 8, lg: 12, xl: 16,
|
||||
} as const;
|
||||
|
||||
export const FontSize = {
|
||||
xs: 9, sm: 10, md: 12, lg: 14, xl: 16,
|
||||
} as const;
|
||||
@@ -39,6 +39,8 @@ export function useDevice() {
|
||||
const ack = await deviceSetup(config);
|
||||
if (ack.result !== 0x01) {
|
||||
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
||||
} else {
|
||||
useDeviceStore.getState().setConfigDirty(false);
|
||||
}
|
||||
return ack;
|
||||
} catch (e: any) {
|
||||
@@ -75,13 +77,14 @@ export function useDevice() {
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setBusy(true);
|
||||
// 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.
|
||||
useDeviceStore.getState().setDeviceStatus('idle');
|
||||
try {
|
||||
await deviceStop();
|
||||
} catch (e: any) {
|
||||
Alert.alert('停止超时', e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
} catch {
|
||||
// best-effort — UI already reflects idle
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
+83
-55
@@ -1,23 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
|
||||
const MAX_DISPLAY_POINTS = 512; // downsample to this many points for rendering
|
||||
const MAX_DISPLAY_POINTS = 512;
|
||||
|
||||
// Downsample an array using peak-hold (envelope) method
|
||||
// sampleFreq code → Hz (from SAMPLE_FREQ_TABLE)
|
||||
const SAMPLE_FREQ_HZ: Record<number, number> = {
|
||||
0x00: 250000, 0x01: 125000, 0x02: 62500, 0x03: 31250,
|
||||
0x04: 15600, 0x05: 7800, 0x06: 3900, 0x07: 1950,
|
||||
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 start = Math.floor(i * ratio);
|
||||
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
|
||||
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];
|
||||
}
|
||||
if (Math.abs(data[j]) > maxAbs) { maxAbs = Math.abs(data[j]); maxVal = data[j]; }
|
||||
}
|
||||
out[i] = maxVal;
|
||||
}
|
||||
@@ -25,58 +31,80 @@ function downsample(data: Float64Array, targetLen: number): Float64Array {
|
||||
}
|
||||
|
||||
export interface WaveformData {
|
||||
channels: Float64Array[]; // downsampled μV data per channel
|
||||
timeMs: Float64Array; // time axis in ms
|
||||
minLogUV: number; // log10 of min absolute non-zero value
|
||||
maxLogUV: number; // log10 of max absolute value
|
||||
sampleDepth: number; // original samples per channel
|
||||
channels: Float64Array[]; // downsampled μV per channel
|
||||
timeMs: Float64Array; // time axis in ms (physical)
|
||||
totalTimeMs: number; // total window duration in ms
|
||||
minLogUV: number; // log10(min |value|) for log scale
|
||||
maxLogUV: number; // log10(max |value|) for log scale
|
||||
minUV: number; // actual min value (signed) for linear scale
|
||||
maxUV: number; // actual max value (signed) for linear scale
|
||||
sampleDepth: number;
|
||||
}
|
||||
|
||||
export function computeWaveformData(
|
||||
frame: MeasurementFrame,
|
||||
visibleChannels: boolean[],
|
||||
sampleFreqCode: number,
|
||||
): WaveformData | null {
|
||||
const { adcUV } = frame;
|
||||
const sampleDepth = adcUV[0]?.length ?? 0;
|
||||
if (sampleDepth === 0) return null;
|
||||
|
||||
const sampleHz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
|
||||
const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
|
||||
|
||||
// Physical time axis
|
||||
const timeMs = new Float64Array(displayLen);
|
||||
for (let i = 0; i < displayLen; i++) {
|
||||
timeMs[i] = (i / sampleHz) * 1000;
|
||||
}
|
||||
const totalTimeMs = ((displayLen - 1) / sampleHz) * 1000;
|
||||
|
||||
let logMin = Infinity;
|
||||
let logMax = -Infinity;
|
||||
let linMin = Infinity;
|
||||
let linMax = -Infinity;
|
||||
|
||||
const channels: Float64Array[] = adcUV.map((ch, idx) => {
|
||||
if (!visibleChannels[idx]) return new Float64Array(displayLen);
|
||||
const ds = downsample(ch, displayLen);
|
||||
for (let i = 0; i < ds.length; i++) {
|
||||
const v = ds[i];
|
||||
const absV = Math.abs(v);
|
||||
if (absV > 1e-9) {
|
||||
logMin = Math.min(logMin, absV);
|
||||
logMax = Math.max(logMax, absV);
|
||||
}
|
||||
linMin = Math.min(linMin, v);
|
||||
linMax = Math.max(linMax, v);
|
||||
}
|
||||
return ds;
|
||||
});
|
||||
|
||||
if (!isFinite(logMin)) logMin = 1e-3;
|
||||
if (!isFinite(logMax)) logMax = 1e6;
|
||||
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;
|
||||
|
||||
return {
|
||||
channels, timeMs, totalTimeMs,
|
||||
minLogUV: Math.log10(logMin), maxLogUV: Math.log10(logMax),
|
||||
minUV: linMin, maxUV: linMax,
|
||||
sampleDepth,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!frame) return null;
|
||||
|
||||
const { adcUV, meta } = frame;
|
||||
const sampleDepth = adcUV[0]?.length ?? 0;
|
||||
if (sampleDepth === 0) return null;
|
||||
|
||||
// Build time axis in ms using sample freq (approximation)
|
||||
// We don't have actual sample rate here, so we use index/total
|
||||
// The real time axis depends on the sendFreq and sampleFreq config
|
||||
// Use normalised 0–1 for now, App can scale by actual period
|
||||
const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
|
||||
const timeMs = new Float64Array(displayLen);
|
||||
for (let i = 0; i < displayLen; i++) {
|
||||
timeMs[i] = i / displayLen; // normalised 0–1 (multiply by period in ms externally)
|
||||
}
|
||||
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
|
||||
const channels: Float64Array[] = adcUV.map((ch, idx) => {
|
||||
if (!visibleChannels[idx]) return new Float64Array(displayLen);
|
||||
const ds = downsample(ch, displayLen);
|
||||
for (let i = 0; i < ds.length; i++) {
|
||||
const absV = Math.abs(ds[i]);
|
||||
if (absV > 1e-9) {
|
||||
globalMin = Math.min(globalMin, absV);
|
||||
globalMax = Math.max(globalMax, absV);
|
||||
}
|
||||
}
|
||||
return ds;
|
||||
});
|
||||
|
||||
if (!isFinite(globalMin)) globalMin = 1e-3;
|
||||
if (!isFinite(globalMax)) globalMax = 1e6;
|
||||
|
||||
return {
|
||||
channels,
|
||||
timeMs,
|
||||
minLogUV: Math.log10(globalMin),
|
||||
maxLogUV: Math.log10(globalMax),
|
||||
sampleDepth,
|
||||
};
|
||||
}, [frame, visibleChannels]);
|
||||
return computeWaveformData(frame, visibleChannels, sampleFreqCode);
|
||||
}, [frame, visibleChannels, sampleFreqCode]);
|
||||
}
|
||||
|
||||
+15
-4
@@ -58,10 +58,11 @@ export interface MeasurementFrame {
|
||||
adcRaw: Int32Array[];
|
||||
// Converted to μV: raw / accNum / gain
|
||||
adcUV: Float64Array[];
|
||||
accNum: number; // stack count at time of reception
|
||||
gain: number; // gain multiplier at time of reception
|
||||
timestamp: number;// local ms timestamp when received
|
||||
frameId: number; // sequential frame counter
|
||||
accNum: number; // stack count at time of reception
|
||||
gain: number; // gain multiplier at time of reception
|
||||
sampleFreqCode:number; // ADC sample frequency code (from SetupConfig)
|
||||
timestamp: number; // local ms timestamp when received
|
||||
frameId: number; // sequential frame counter
|
||||
}
|
||||
|
||||
// Split frame session state
|
||||
@@ -81,3 +82,13 @@ export type ConnectionStatus =
|
||||
|
||||
// Device running state
|
||||
export type DeviceStatus = 'idle' | 'running' | 'single';
|
||||
|
||||
// ── Gate config (profile screen, persisted in deviceStore) ─────────────────
|
||||
export type GateSpacing = 'log' | 'linear';
|
||||
|
||||
export interface GateConfig {
|
||||
tStart: number; // μs
|
||||
tEnd: number; // μs
|
||||
count: number;
|
||||
spacing: GateSpacing;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* TEMF v1 binary format
|
||||
*
|
||||
* Header (64 bytes):
|
||||
* [0-3] u8[4] MAGIC = "TEMF" (0x54 0x45 0x4D 0x46)
|
||||
* [4] u8 version = 0x01
|
||||
* [5] u8 channelNum
|
||||
* [6-7] u16 LE sampleDepth
|
||||
* [8] u8 sampleFreqCode
|
||||
* [9-10] u16 LE accNum
|
||||
* [11] u8 ampRatio
|
||||
* [12] u8 srcMode
|
||||
* [13-20] f64 LE timestamp (ms, local)
|
||||
* [21-28] f64 LE latitude (degrees WGS-84)
|
||||
* [29-36] f64 LE longitude (degrees WGS-84)
|
||||
* [37-40] u32 LE utc (seconds since Unix epoch)
|
||||
* [41-44] f32 LE altitude (meters)
|
||||
* [45-46] u16 LE batteryVolt (raw ADC)
|
||||
* [47-48] i16 LE temperature (raw ADC)
|
||||
* [49-50] u16 LE current (raw ADC)
|
||||
* [51] u8 gpsStatus
|
||||
* [52] u8 sdStatus
|
||||
* [53-54] i16 LE roll (degrees × 100)
|
||||
* [55-56] i16 LE pitch (degrees × 100)
|
||||
* [57-58] i16 LE yaw (degrees × 10)
|
||||
* [59-63] u8[5] reserved (zero)
|
||||
*
|
||||
* Data (after header):
|
||||
* int32 LE, row-major: channelNum rows × sampleDepth columns
|
||||
* Values are raw ADC counts; calibrated μV = raw / accNum / AMP_GAIN[ampRatio]
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { AMP_GAIN } from '../protocol/constants';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
|
||||
const MAGIC = [0x54, 0x45, 0x4d, 0x46] as const; // "TEMF"
|
||||
const VERSION = 0x01;
|
||||
export const HEADER_SIZE = 64;
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoadedBin {
|
||||
adcUV: Float64Array[];
|
||||
sampleDepth: number;
|
||||
channelNum: number;
|
||||
sampleFreqCode: number;
|
||||
accNum: number;
|
||||
ampRatio: number;
|
||||
srcMode: number;
|
||||
timestamp: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
utc: number;
|
||||
altitude: number;
|
||||
batteryVolt: number;
|
||||
temperature: number;
|
||||
current: number;
|
||||
gpsStatus: number;
|
||||
sdStatus: number;
|
||||
roll: number;
|
||||
pitch: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
// ── Base64 ↔ Uint8Array helpers ─────────────────────────────────────────────
|
||||
|
||||
export function u8ToBase64(u8: Uint8Array): string {
|
||||
const chunk = 8192;
|
||||
let str = '';
|
||||
for (let i = 0; i < u8.length; i += chunk) {
|
||||
str += String.fromCharCode(...u8.subarray(i, Math.min(i + chunk, u8.length)));
|
||||
}
|
||||
return btoa(str);
|
||||
}
|
||||
|
||||
export function base64ToU8(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ── Pure encode / decode ────────────────────────────────────────────────────
|
||||
|
||||
/** Encode a MeasurementFrame into TEMF v1 bytes. No IO. */
|
||||
export function encodeBin(frame: MeasurementFrame): Uint8Array {
|
||||
const m = frame.meta;
|
||||
const ch = frame.adcRaw.length;
|
||||
const spc = frame.adcRaw[0]?.length ?? 0;
|
||||
const buf = new ArrayBuffer(HEADER_SIZE + ch * spc * 4);
|
||||
const dv = new DataView(buf);
|
||||
|
||||
MAGIC.forEach((b, i) => dv.setUint8(i, b));
|
||||
dv.setUint8(4, VERSION);
|
||||
dv.setUint8(5, ch);
|
||||
dv.setUint16(6, spc, true);
|
||||
dv.setUint8(8, frame.sampleFreqCode);
|
||||
dv.setUint16(9, frame.accNum, true);
|
||||
dv.setUint8(11, m.ampRatio);
|
||||
dv.setUint8(12, m.sourceMode);
|
||||
dv.setFloat64(13, frame.timestamp, true);
|
||||
dv.setFloat64(21, m.latitude, true);
|
||||
dv.setFloat64(29, m.longitude, true);
|
||||
dv.setUint32(37, m.utc, true);
|
||||
dv.setFloat32(41, m.altitude, true);
|
||||
dv.setUint16(45, m.batteryVolt, true);
|
||||
dv.setInt16(47, m.temperature, true);
|
||||
dv.setUint16(49, m.current, true);
|
||||
dv.setUint8(51, m.gpsStatus);
|
||||
dv.setUint8(52, m.sdStatus);
|
||||
dv.setInt16(53, Math.round(m.roll * 100), true);
|
||||
dv.setInt16(55, Math.round(m.pitch * 100), true);
|
||||
dv.setInt16(57, Math.round(m.yaw * 10), true);
|
||||
// [59-63] reserved — zero by default
|
||||
|
||||
let off = HEADER_SIZE;
|
||||
for (const c of frame.adcRaw) {
|
||||
for (let i = 0; i < c.length; i++) { dv.setInt32(off, c[i], true); off += 4; }
|
||||
}
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** Decode TEMF v1 bytes into a LoadedBin. Returns null if invalid. No IO. */
|
||||
export function decodeBin(bytes: Uint8Array): LoadedBin | null {
|
||||
if (bytes.length < HEADER_SIZE) return null;
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
if (MAGIC.some((b, i) => dv.getUint8(i) !== b)) return null;
|
||||
if (dv.getUint8(4) !== VERSION) return null;
|
||||
|
||||
const channelNum = dv.getUint8(5);
|
||||
const sampleDepth = dv.getUint16(6, true);
|
||||
const sampleFreqCode = dv.getUint8(8);
|
||||
const accNum = dv.getUint16(9, true);
|
||||
const ampRatio = dv.getUint8(11);
|
||||
const srcMode = dv.getUint8(12);
|
||||
const timestamp = dv.getFloat64(13, true);
|
||||
const latitude = dv.getFloat64(21, true);
|
||||
const longitude = dv.getFloat64(29, true);
|
||||
const utc = dv.getUint32(37, true);
|
||||
const altitude = dv.getFloat32(41, true);
|
||||
const batteryVolt = dv.getUint16(45, true);
|
||||
const temperature = dv.getInt16(47, true);
|
||||
const current = dv.getUint16(49, true);
|
||||
const gpsStatus = dv.getUint8(51);
|
||||
const sdStatus = dv.getUint8(52);
|
||||
const roll = dv.getInt16(53, true) / 100;
|
||||
const pitch = dv.getInt16(55, true) / 100;
|
||||
const yaw = dv.getInt16(57, true) / 10;
|
||||
|
||||
const expected = HEADER_SIZE + channelNum * sampleDepth * 4;
|
||||
if (bytes.length < expected) return null;
|
||||
|
||||
const gain = AMP_GAIN[ampRatio] ?? 1;
|
||||
const acc = Math.max(accNum, 1);
|
||||
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;
|
||||
off += 4;
|
||||
}
|
||||
adcUV.push(channel);
|
||||
}
|
||||
|
||||
return {
|
||||
adcUV, sampleDepth, channelNum, sampleFreqCode,
|
||||
accNum, ampRatio, srcMode, timestamp,
|
||||
latitude, longitude, utc, altitude,
|
||||
batteryVolt, temperature, current,
|
||||
gpsStatus, sdStatus, roll, pitch, yaw,
|
||||
};
|
||||
}
|
||||
|
||||
// ── File IO ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function readFileBytes(path: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const b64 = await FileSystem.readAsStringAsync(path, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return base64ToU8(b64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFileBytes(path: string, data: Uint8Array): Promise<void> {
|
||||
await FileSystem.writeAsStringAsync(path, u8ToBase64(data), {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveBinFile(path: string, frame: MeasurementFrame): Promise<void> {
|
||||
await writeFileBytes(path, encodeBin(frame));
|
||||
}
|
||||
|
||||
export async function loadBinFile(path: string): Promise<LoadedBin | null> {
|
||||
const bytes = await readFileBytes(path);
|
||||
return bytes ? decodeBin(bytes) : null;
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import { useConnectionStore } from '../stores/connectionStore';
|
||||
import { useDeviceStore } from '../stores/deviceStore';
|
||||
import { useDataStore } from '../stores/dataStore';
|
||||
|
||||
let frameCounter = 0;
|
||||
|
||||
// Pending ACK promise resolver keyed by func code
|
||||
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
||||
|
||||
@@ -33,7 +31,7 @@ export function handleIncomingFrame(frame: RawFrame) {
|
||||
const dataStore = useDataStore.getState();
|
||||
|
||||
switch (func) {
|
||||
// ── ACK responses ──────────────────────────────────────────────
|
||||
// ── ACK responses ──────────────────────────────────────────────────
|
||||
case FuncCode.SETUP_ACK:
|
||||
case FuncCode.CONTINUOUS_ACK:
|
||||
case FuncCode.SINGLE_ACK:
|
||||
@@ -97,6 +95,10 @@ function processMeasurementPayload(
|
||||
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;
|
||||
|
||||
const meta = parseMetadata(payload);
|
||||
if (!meta) return;
|
||||
|
||||
@@ -109,12 +111,27 @@ function processMeasurementPayload(
|
||||
adcRaw,
|
||||
adcUV,
|
||||
accNum,
|
||||
gain: cfg.ampRatio,
|
||||
timestamp: Date.now(),
|
||||
frameId: frameCounter++,
|
||||
gain: cfg.ampRatio,
|
||||
sampleFreqCode: cfg.sampleFreq,
|
||||
timestamp: Date.now(),
|
||||
frameId: dataStore.nextFrameId(),
|
||||
};
|
||||
|
||||
dataStore.addFrame(measurementFrame);
|
||||
|
||||
devStore.updateTelemetry({
|
||||
batteryVolt: meta.batteryVolt,
|
||||
temperature: meta.temperature,
|
||||
gpsStatus: meta.gpsStatus,
|
||||
sdStatus: meta.sdStatus,
|
||||
frameId: measurementFrame.frameId,
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types';
|
||||
import * as BinLoader from './BinLoader';
|
||||
import * as TemBundle from './TemBundle';
|
||||
|
||||
const DATA_DIR = (FileSystem.documentDirectory ?? '') + 'tem_data/';
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectInfo {
|
||||
projectId: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
frameCount: number;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
export interface PersistedFrame {
|
||||
frameId: number;
|
||||
sessionId: string;
|
||||
timestamp: number;
|
||||
accNum: number;
|
||||
gain: number;
|
||||
binPath: string | null;
|
||||
meta: MeasurementMeta;
|
||||
}
|
||||
|
||||
// ── DB init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!_db) {
|
||||
_db = await SQLite.openDatabaseAsync('tem.db');
|
||||
await _db.execAsync(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS frames (
|
||||
frame_id INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
ts INTEGER,
|
||||
utc INTEGER, lon REAL, lat REAL, alt REAL,
|
||||
ch_num INTEGER, acc_num INTEGER, gain REAL,
|
||||
gps_st INTEGER, sd_st INTEGER, amp_ratio INTEGER,
|
||||
current INTEGER, temp INTEGER, batt INTEGER,
|
||||
roll REAL, pitch REAL, yaw REAL, src_mode INTEGER,
|
||||
bin_path TEXT,
|
||||
PRIMARY KEY (frame_id, session_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
|
||||
`);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
async function ensureDataDir() {
|
||||
const info = await FileSystem.getInfoAsync(DATA_DIR);
|
||||
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
export async function initStorage(): Promise<void> {
|
||||
await getDb();
|
||||
await ensureDataDir();
|
||||
}
|
||||
|
||||
// ── Project CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateProjectId(): string {
|
||||
const now = new Date();
|
||||
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
||||
return (
|
||||
`P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
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()],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<ProjectInfo[]> {
|
||||
const d = await getDb();
|
||||
const rows = await d.getAllAsync<{
|
||||
project_id: string; name: string; created_at: number; line_count: number;
|
||||
}>(
|
||||
`SELECT p.project_id, p.name, p.created_at,
|
||||
COUNT(s.session_id) AS line_count
|
||||
FROM projects p
|
||||
LEFT JOIN sessions s ON s.project_id = p.project_id
|
||||
GROUP BY p.project_id
|
||||
ORDER BY p.created_at DESC`,
|
||||
);
|
||||
return rows.map(r => ({
|
||||
projectId: r.project_id,
|
||||
name: r.name,
|
||||
createdAt: r.created_at,
|
||||
lineCount: r.line_count,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function renameProject(projectId: string, name: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync('UPDATE projects SET name = ? WHERE project_id = ?', [name.trim(), projectId]);
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]);
|
||||
}
|
||||
|
||||
// ── Session CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function ensureSession(sessionId: string, projectId?: string | null): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync(
|
||||
'INSERT OR IGNORE INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
|
||||
[sessionId, Date.now(), projectId ?? null],
|
||||
);
|
||||
if (projectId) {
|
||||
await d.runAsync(
|
||||
'UPDATE sessions SET project_id = ? WHERE session_id = ? AND project_id IS NULL',
|
||||
[projectId, sessionId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<SessionInfo[]> {
|
||||
const d = await getDb();
|
||||
const rows = await d.getAllAsync<{
|
||||
session_id: string; created_at: number; frame_count: number; project_id: string | null;
|
||||
}>(
|
||||
'SELECT session_id, created_at, frame_count, project_id FROM sessions ORDER BY created_at DESC',
|
||||
);
|
||||
return rows.map(r => ({
|
||||
sessionId: r.session_id,
|
||||
createdAt: r.created_at,
|
||||
frameCount: r.frame_count,
|
||||
projectId: r.project_id,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listSessionsByProject(projectId: string): Promise<SessionInfo[]> {
|
||||
const d = await getDb();
|
||||
const rows = await d.getAllAsync<{
|
||||
session_id: string; created_at: number; frame_count: number;
|
||||
}>(
|
||||
'SELECT session_id, created_at, frame_count FROM sessions WHERE project_id = ? ORDER BY created_at DESC',
|
||||
[projectId],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
sessionId: r.session_id,
|
||||
createdAt: r.created_at,
|
||||
frameCount: r.frame_count,
|
||||
projectId,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function assignSessionToProject(sessionId: string, projectId: string | null): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync('UPDATE sessions SET project_id = ? WHERE session_id = ?', [projectId, sessionId]);
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(DATA_DIR);
|
||||
await Promise.all(
|
||||
files
|
||||
.filter(f => f.startsWith(sessionId))
|
||||
.map(f => FileSystem.deleteAsync(DATA_DIR + f, { idempotent: true })),
|
||||
);
|
||||
} catch { /* directory may not exist */ }
|
||||
await d.runAsync('DELETE FROM frames WHERE session_id = ?', [sessionId]);
|
||||
await d.runAsync('DELETE FROM sessions WHERE session_id = ?', [sessionId]);
|
||||
}
|
||||
|
||||
// ── Frame persistence ────────────────────────────────────────────────────────
|
||||
|
||||
export async function persistFrame(frame: MeasurementFrame, sessionId: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
const m = frame.meta;
|
||||
|
||||
let binPath: string | null = null;
|
||||
try {
|
||||
const fname = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
|
||||
binPath = DATA_DIR + fname;
|
||||
await BinLoader.saveBinFile(binPath, frame);
|
||||
} catch { binPath = null; }
|
||||
|
||||
await d.runAsync(
|
||||
`INSERT OR REPLACE 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
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
frame.frameId, sessionId, frame.timestamp,
|
||||
m.utc, m.longitude, m.latitude, m.altitude,
|
||||
m.channelNum, frame.accNum, frame.gain,
|
||||
m.gpsStatus, m.sdStatus, m.ampRatio,
|
||||
m.current, m.temperature, m.batteryVolt,
|
||||
m.roll, m.pitch, m.yaw, m.sourceMode, binPath,
|
||||
],
|
||||
);
|
||||
|
||||
await d.runAsync(
|
||||
'UPDATE sessions SET frame_count = frame_count + 1 WHERE session_id = ?',
|
||||
[sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSessionFrames(sessionId: string): Promise<PersistedFrame[]> {
|
||||
const d = await getDb();
|
||||
const rows = await d.getAllAsync<any>(
|
||||
'SELECT * FROM frames WHERE session_id = ? ORDER BY frame_id DESC',
|
||||
[sessionId],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
frameId: r.frame_id,
|
||||
sessionId: r.session_id,
|
||||
timestamp: r.ts,
|
||||
accNum: r.acc_num,
|
||||
gain: r.gain,
|
||||
binPath: r.bin_path,
|
||||
meta: {
|
||||
devId: 0, utc: r.utc,
|
||||
longitude: r.lon, latitude: r.lat, altitude: r.alt, height: 0,
|
||||
sdStatus: r.sd_st, gpsStatus: r.gps_st, ampRatio: r.amp_ratio,
|
||||
roll: r.roll, pitch: r.pitch, yaw: r.yaw,
|
||||
channelNum: r.ch_num, current: r.current,
|
||||
temperature: r.temp, batteryVolt: r.batt, sourceMode: r.src_mode,
|
||||
} as MeasurementMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteFrame(frameId: number, sessionId: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
const row = await d.getFirstAsync<{ bin_path: string | null }>(
|
||||
'SELECT bin_path FROM frames WHERE frame_id = ? AND session_id = ?',
|
||||
[frameId, sessionId],
|
||||
);
|
||||
if (row?.bin_path) {
|
||||
try { await FileSystem.deleteAsync(row.bin_path, { idempotent: true }); } catch { /* ignore */ }
|
||||
}
|
||||
await d.runAsync('DELETE FROM frames WHERE frame_id = ? AND session_id = ?', [frameId, sessionId]);
|
||||
await d.runAsync('UPDATE sessions SET frame_count = MAX(0, frame_count - 1) WHERE session_id = ?', [sessionId]);
|
||||
}
|
||||
|
||||
export async function getMaxFrameId(sessionId: string): Promise<number> {
|
||||
const d = await getDb();
|
||||
const row = await d.getFirstAsync<{ max_id: number | null }>(
|
||||
'SELECT MAX(frame_id) AS max_id FROM frames WHERE session_id = ?',
|
||||
[sessionId],
|
||||
);
|
||||
return row?.max_id ?? -1;
|
||||
}
|
||||
|
||||
// ── CSV export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function exportSessionMetaCsv(sessionId: string): Promise<string> {
|
||||
const frames = await loadSessionFrames(sessionId);
|
||||
if (frames.length === 0) throw new Error('会话无数据');
|
||||
|
||||
const { formatUtc } = await import('../utils/format');
|
||||
|
||||
const maxCh = frames.reduce((m, f) => Math.max(m, f.meta.channelNum), 0);
|
||||
const chHeaders = Array.from({ length: maxCh }, (_, i) => `CH${i + 1}_peak_uV`).join(',');
|
||||
const header = `FrameID,UTC,Longitude,Latitude,Altitude,GPS,SD,AmpRatio,Current,Temp,Battery,Roll,Pitch,Yaw,${chHeaders}`;
|
||||
|
||||
const rows: string[] = [];
|
||||
for (const f of frames) {
|
||||
const m = f.meta;
|
||||
let peaks: string[] = Array(maxCh).fill('');
|
||||
if (f.binPath) {
|
||||
const bin = await BinLoader.loadBinFile(f.binPath);
|
||||
if (bin) {
|
||||
peaks = Array.from({ length: maxCh }, (_, i) => {
|
||||
const ch = bin.adcUV[i];
|
||||
if (!ch) return '';
|
||||
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||
return pk.toFixed(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.push([
|
||||
f.frameId, formatUtc(m.utc),
|
||||
m.longitude.toFixed(7), m.latitude.toFixed(7), m.altitude.toFixed(2),
|
||||
m.gpsStatus, m.sdStatus, m.ampRatio, m.current, m.temperature, m.batteryVolt,
|
||||
m.roll.toFixed(2), m.pitch.toFixed(2), m.yaw.toFixed(2),
|
||||
...peaks,
|
||||
].join(','));
|
||||
}
|
||||
|
||||
const csv = [header, ...rows].join('\n');
|
||||
await ensureDataDir();
|
||||
const path = DATA_DIR + `TEM_${sessionId}.csv`;
|
||||
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── .tem export ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function exportProject(
|
||||
projectId: string,
|
||||
deviceConfig: SetupConfig,
|
||||
gateConfig: GateConfig,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<string> {
|
||||
const d = await getDb();
|
||||
|
||||
const proj = await d.getFirstAsync<{ name: string; created_at: number }>(
|
||||
'SELECT name, created_at FROM projects WHERE project_id = ?',
|
||||
[projectId],
|
||||
);
|
||||
if (!proj) throw new Error('工程不存在');
|
||||
|
||||
const sessions = await listSessionsByProject(projectId);
|
||||
|
||||
// Collect all frames + bin bytes
|
||||
const bins: Record<string, Uint8Array> = {};
|
||||
const sessionManifests: TemBundle.SessionManifest[] = [];
|
||||
let totalFrames = sessions.reduce((s, x) => s + x.frameCount, 0);
|
||||
let done = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
const frames = await loadSessionFrames(session.sessionId);
|
||||
for (const f of frames) {
|
||||
if (f.binPath) {
|
||||
const bytes = await BinLoader.readFileBytes(f.binPath);
|
||||
if (bytes) {
|
||||
const relPath = TemBundle.bundleBinPath(session.sessionId, f.frameId);
|
||||
bins[relPath] = bytes;
|
||||
}
|
||||
}
|
||||
onProgress?.(++done, totalFrames);
|
||||
}
|
||||
sessionManifests.push({
|
||||
id: session.sessionId,
|
||||
created_at: session.createdAt,
|
||||
frame_count: session.frameCount,
|
||||
device_config: deviceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest: TemBundle.ProjectManifest = {
|
||||
schema_version: '1.0',
|
||||
exported_at: new Date().toISOString(),
|
||||
project: { id: projectId, name: proj.name, created_at: proj.created_at },
|
||||
device_config: deviceConfig,
|
||||
gate_config: gateConfig,
|
||||
sessions: sessionManifests,
|
||||
};
|
||||
|
||||
const bundleBytes = await TemBundle.packBundle(manifest, bins);
|
||||
|
||||
await ensureDataDir();
|
||||
const outPath = DATA_DIR + `TEM_${proj.name}_${Date.now()}.tem`;
|
||||
await BinLoader.writeFileBytes(outPath, bundleBytes);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
// ── .tem import ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImportResult {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionCount: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
export async function importProject(
|
||||
bundleUri: string,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<ImportResult> {
|
||||
await ensureDataDir();
|
||||
const d = await getDb();
|
||||
|
||||
// Copy to temp path (handles content:// URIs on Android)
|
||||
const tmpPath = DATA_DIR + `tmp_import_${Date.now()}.tem`;
|
||||
try {
|
||||
await FileSystem.copyAsync({ from: bundleUri, to: tmpPath });
|
||||
} catch {
|
||||
// URI may already be a file:// path — try reading directly
|
||||
}
|
||||
|
||||
let bundleBytes: Uint8Array | null = null;
|
||||
try {
|
||||
bundleBytes = await BinLoader.readFileBytes(tmpPath);
|
||||
} catch { /* fall through */ }
|
||||
|
||||
if (!bundleBytes) {
|
||||
bundleBytes = await BinLoader.readFileBytes(bundleUri);
|
||||
}
|
||||
if (!bundleBytes) throw new Error('无法读取 .tem 文件');
|
||||
|
||||
// Clean up temp file (don't await — non-critical)
|
||||
FileSystem.deleteAsync(tmpPath, { idempotent: true }).catch(() => {});
|
||||
|
||||
const { manifest, bins } = await TemBundle.parseBundle(bundleBytes);
|
||||
|
||||
// Allocate new project ID to avoid collision
|
||||
const newProjectId = generateProjectId();
|
||||
await d.runAsync(
|
||||
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
|
||||
[newProjectId, manifest.project.name, manifest.project.created_at],
|
||||
);
|
||||
|
||||
const totalFrames = Object.keys(bins).length;
|
||||
let done = 0;
|
||||
let importedFrames = 0;
|
||||
|
||||
for (const session of manifest.sessions) {
|
||||
// Use original session ID if not already in DB; otherwise suffix with timestamp
|
||||
const existing = await d.getFirstAsync<{ session_id: string }>(
|
||||
'SELECT session_id FROM sessions WHERE session_id = ?',
|
||||
[session.id],
|
||||
);
|
||||
const newSessionId = existing ? `${session.id}_${Date.now()}` : session.id;
|
||||
|
||||
await d.runAsync(
|
||||
'INSERT INTO sessions (session_id, created_at, frame_count, project_id) VALUES (?,?,0,?)',
|
||||
[newSessionId, session.created_at, newProjectId],
|
||||
);
|
||||
|
||||
let sessionFrameCount = 0;
|
||||
for (const [relPath, binBytes] of Object.entries(bins)) {
|
||||
const parsed = TemBundle.parseBinPath(relPath);
|
||||
if (!parsed || parsed.sessionId !== session.id) continue;
|
||||
|
||||
const { frameId } = parsed;
|
||||
const meta = BinLoader.decodeBin(binBytes);
|
||||
if (!meta) { onProgress?.(++done, totalFrames); continue; }
|
||||
|
||||
// Write bin file to DATA_DIR
|
||||
const fname = `${newSessionId}_f${String(frameId).padStart(6, '0')}.bin`;
|
||||
const binPath = DATA_DIR + fname;
|
||||
await BinLoader.writeFileBytes(binPath, binBytes);
|
||||
|
||||
// Insert frame record
|
||||
await d.runAsync(
|
||||
`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
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
frameId, newSessionId, meta.timestamp,
|
||||
meta.utc, meta.longitude, meta.latitude, meta.altitude,
|
||||
meta.channelNum, meta.accNum, meta.ampRatio,
|
||||
meta.gpsStatus, meta.sdStatus, meta.ampRatio,
|
||||
meta.current, meta.temperature, meta.batteryVolt,
|
||||
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,
|
||||
],
|
||||
);
|
||||
|
||||
sessionFrameCount++;
|
||||
importedFrames++;
|
||||
onProgress?.(++done, totalFrames);
|
||||
}
|
||||
|
||||
await d.runAsync('UPDATE sessions SET frame_count = ? WHERE session_id = ?', [sessionFrameCount, newSessionId]);
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: newProjectId,
|
||||
projectName: manifest.project.name,
|
||||
sessionCount: manifest.sessions.length,
|
||||
frameCount: importedFrames,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { zip, unzip } from 'fflate';
|
||||
import type { AsyncZippable, AsyncZipOptions } from 'fflate';
|
||||
import type { SetupConfig, GateConfig } from '../protocol/types';
|
||||
|
||||
// ── Manifest schema ────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionManifest {
|
||||
id: string;
|
||||
created_at: number;
|
||||
frame_count: number;
|
||||
device_config: SetupConfig;
|
||||
}
|
||||
|
||||
export interface ProjectManifest {
|
||||
schema_version: '1.0';
|
||||
exported_at: string;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: number;
|
||||
};
|
||||
device_config: SetupConfig;
|
||||
gate_config: GateConfig;
|
||||
sessions: SessionManifest[];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENC = new TextEncoder();
|
||||
const DEC = new TextDecoder();
|
||||
|
||||
function promiseZip(files: AsyncZippable): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
zip(files, (err, data) => (err ? reject(err) : resolve(data)));
|
||||
});
|
||||
}
|
||||
|
||||
function promiseUnzip(data: Uint8Array): Promise<Record<string, Uint8Array>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
unzip(data, (err, files) => (err ? reject(err) : resolve(files)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pack a .tem bundle (DEFLATE-compressed ZIP).
|
||||
* bins: relative path → raw bin bytes, e.g. "frames/20260607_042316/000001.bin"
|
||||
*/
|
||||
export async function packBundle(
|
||||
manifest: ProjectManifest,
|
||||
bins: Record<string, Uint8Array>,
|
||||
): Promise<Uint8Array> {
|
||||
const opts: AsyncZipOptions = { level: 6 };
|
||||
const files: AsyncZippable = {
|
||||
'project.json': [ENC.encode(JSON.stringify(manifest, null, 2)), opts],
|
||||
};
|
||||
for (const [path, data] of Object.entries(bins)) {
|
||||
files[path] = [data, opts];
|
||||
}
|
||||
return promiseZip(files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack a .tem bundle.
|
||||
* Returns the manifest and raw bin bytes keyed by relative path.
|
||||
*/
|
||||
export async function parseBundle(data: Uint8Array): Promise<{
|
||||
manifest: ProjectManifest;
|
||||
bins: Record<string, Uint8Array>;
|
||||
}> {
|
||||
const files = await promiseUnzip(data);
|
||||
|
||||
const manifestBytes = files['project.json'];
|
||||
if (!manifestBytes) throw new Error('.tem 包缺少 project.json');
|
||||
|
||||
const manifest = JSON.parse(DEC.decode(manifestBytes)) as ProjectManifest;
|
||||
if (manifest.schema_version !== '1.0') {
|
||||
throw new Error(`不支持的版本: schema_version=${manifest.schema_version}`);
|
||||
}
|
||||
|
||||
const bins: Record<string, Uint8Array> = {};
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
if (path !== 'project.json') bins[path] = bytes;
|
||||
}
|
||||
|
||||
return { manifest, bins };
|
||||
}
|
||||
|
||||
/** Extract session ID and frame ID from a bundle bin path. */
|
||||
export function parseBinPath(relPath: string): { sessionId: string; frameId: number } | null {
|
||||
// Expected: "frames/{sessionId}/{frameId:06d}.bin"
|
||||
const m = relPath.match(/^frames\/([^/]+)\/(\d+)\.bin$/);
|
||||
if (!m) return null;
|
||||
return { sessionId: m[1], frameId: parseInt(m[2], 10) };
|
||||
}
|
||||
|
||||
/** Build the bundle-relative path for a frame bin. */
|
||||
export function bundleBinPath(sessionId: string, frameId: number): string {
|
||||
return `frames/${sessionId}/${String(frameId).padStart(6, '0')}.bin`;
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
|
||||
import type { ConnectionStatus } from '../protocol/types';
|
||||
import { fileStorage } from '../utils/storage';
|
||||
|
||||
interface ConnectionState {
|
||||
status: ConnectionStatus;
|
||||
host: string;
|
||||
port: number;
|
||||
lastError: string;
|
||||
setStatus: (s: ConnectionStatus) => void;
|
||||
setHost: (h: string) => void;
|
||||
setPort: (p: number) => void;
|
||||
setError: (e: string) => void;
|
||||
status: ConnectionStatus;
|
||||
host: string;
|
||||
port: number;
|
||||
lastError: string;
|
||||
modalVisible: boolean;
|
||||
setStatus: (s: ConnectionStatus) => void;
|
||||
setHost: (h: string) => void;
|
||||
setPort: (p: number) => void;
|
||||
setError: (e: string) => void;
|
||||
showModal: () => void;
|
||||
hideModal: () => void;
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
status: 'disconnected',
|
||||
host: DEFAULT_DEVICE_IP,
|
||||
port: DEFAULT_TCP_PORT,
|
||||
lastError: '',
|
||||
setStatus: (status) => set({ status }),
|
||||
setHost: (host) => set({ host }),
|
||||
setPort: (port) => set({ port }),
|
||||
setError: (lastError) => set({ lastError, status: 'error' }),
|
||||
}));
|
||||
export const useConnectionStore = create<ConnectionState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
status: 'disconnected',
|
||||
host: DEFAULT_DEVICE_IP,
|
||||
port: DEFAULT_TCP_PORT,
|
||||
lastError: '',
|
||||
modalVisible: false,
|
||||
setStatus: (status) => set({ status }),
|
||||
setHost: (host) => set({ host }),
|
||||
setPort: (port) => set({ port }),
|
||||
setError: (lastError) => set({ lastError, status: 'error' }),
|
||||
showModal: () => set({ modalVisible: true }),
|
||||
hideModal: () => set({ modalVisible: false }),
|
||||
}),
|
||||
{
|
||||
name: 'connection-config',
|
||||
storage: createJSONStorage(() => fileStorage),
|
||||
partialize: (state) => ({ host: state.host, port: state.port }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+100
-15
@@ -1,29 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { MeasurementFrame } from '../protocol/types';
|
||||
import * as StorageService from '../services/StorageService';
|
||||
import { fileStorage } from '../utils/storage';
|
||||
|
||||
const MAX_HISTORY = 500; // keep last 500 frames in memory
|
||||
const MAX_HISTORY = 500;
|
||||
|
||||
interface DataState {
|
||||
currentFrame: MeasurementFrame | null;
|
||||
history: MeasurementFrame[]; // newest first
|
||||
history: MeasurementFrame[];
|
||||
sessionId: string;
|
||||
projectId: string | null;
|
||||
_frameCounter: number;
|
||||
nextFrameId: () => number;
|
||||
addFrame: (frame: MeasurementFrame) => void;
|
||||
deleteFrame: (frameId: number) => void;
|
||||
clearHistory: () => void;
|
||||
newSession: () => 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. */
|
||||
resumeSession: (sessionId: string, projectId?: string | null) => Promise<void>;
|
||||
/** Change the project association of the current session. */
|
||||
setProject: (projectId: string | null) => Promise<void>;
|
||||
init: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDataStore = create<DataState>((set) => ({
|
||||
currentFrame: null,
|
||||
history: [],
|
||||
sessionId: generateSessionId(),
|
||||
addFrame: (frame) =>
|
||||
set((s) => ({
|
||||
currentFrame: frame,
|
||||
history: [frame, ...s.history].slice(0, MAX_HISTORY),
|
||||
})),
|
||||
clearHistory: () => set({ history: [], currentFrame: null }),
|
||||
newSession: () => set({ sessionId: generateSessionId(), history: [], currentFrame: null }),
|
||||
}));
|
||||
export const useDataStore = create<DataState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentFrame: null,
|
||||
history: [],
|
||||
sessionId: generateSessionId(),
|
||||
projectId: null,
|
||||
_frameCounter: 0,
|
||||
|
||||
nextFrameId: () => {
|
||||
const id = get()._frameCounter;
|
||||
set((s) => ({ _frameCounter: s._frameCounter + 1 }));
|
||||
return id;
|
||||
},
|
||||
|
||||
addFrame: (frame) => {
|
||||
set((s) => ({
|
||||
currentFrame: frame,
|
||||
history: [frame, ...s.history].slice(0, MAX_HISTORY),
|
||||
}));
|
||||
void StorageService.persistFrame(frame, get().sessionId);
|
||||
},
|
||||
|
||||
deleteFrame: (frameId) => {
|
||||
const { sessionId, history, currentFrame } = get();
|
||||
void StorageService.deleteFrame(frameId, sessionId);
|
||||
const next = history.filter((f) => f.frameId !== frameId);
|
||||
set({
|
||||
history: next,
|
||||
currentFrame: currentFrame?.frameId === frameId ? (next[0] ?? null) : currentFrame,
|
||||
});
|
||||
},
|
||||
|
||||
clearHistory: () => set({ history: [], currentFrame: null }),
|
||||
|
||||
newSession: async (projectId) => {
|
||||
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 });
|
||||
},
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
|
||||
setProject: async (projectId) => {
|
||||
const { sessionId } = get();
|
||||
await StorageService.assignSessionToProject(sessionId, projectId);
|
||||
set({ projectId });
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
await StorageService.initStorage();
|
||||
const { sessionId, projectId } = get();
|
||||
await StorageService.ensureSession(sessionId, projectId);
|
||||
const maxId = await StorageService.getMaxFrameId(sessionId);
|
||||
set({ _frameCounter: maxId + 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
|
||||
+55
-27
@@ -1,6 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SetupConfig, DeviceStatus } from '../protocol/types';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { SetupConfig, DeviceStatus, GateConfig } from '../protocol/types';
|
||||
import { DataChannel } from '../protocol/constants';
|
||||
import { fileStorage } from '../utils/storage';
|
||||
|
||||
const DEFAULT_GATE_CONFIG: GateConfig = {
|
||||
tStart: 100,
|
||||
tEnd: 3000,
|
||||
count: 10,
|
||||
spacing: 'log',
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: SetupConfig = {
|
||||
channelNum: 3,
|
||||
@@ -21,18 +30,25 @@ const DEFAULT_CONFIG: SetupConfig = {
|
||||
};
|
||||
|
||||
interface DeviceState {
|
||||
config: SetupConfig;
|
||||
// ── Device config (persisted) ──────────────────────────────────────────────
|
||||
config: SetupConfig;
|
||||
gateConfig: GateConfig;
|
||||
configDirty: boolean; // true when config changed but not yet sent to device
|
||||
// ── Runtime status (not persisted) ────────────────────────────────────────
|
||||
deviceStatus: DeviceStatus;
|
||||
batteryVolt: number; // raw ADC
|
||||
temperature: number; // raw ADC
|
||||
gpsStatus: number;
|
||||
sdStatus: number;
|
||||
frameId: number; // last received frame ID
|
||||
accCount: number; // stacking progress
|
||||
setConfig: (cfg: SetupConfig) => void;
|
||||
updateConfig: (partial: Partial<SetupConfig>) => void;
|
||||
setDeviceStatus: (s: DeviceStatus) => void;
|
||||
updateTelemetry: (data: {
|
||||
// ── Telemetry (not persisted) ─────────────────────────────────────────────
|
||||
batteryVolt: number;
|
||||
temperature: number;
|
||||
gpsStatus: number;
|
||||
sdStatus: number;
|
||||
frameId: number;
|
||||
accCount: number;
|
||||
setConfig: (cfg: SetupConfig) => void;
|
||||
updateConfig: (partial: Partial<SetupConfig>) => void;
|
||||
setGateConfig: (cfg: GateConfig) => void;
|
||||
setConfigDirty: (v: boolean) => void;
|
||||
setDeviceStatus: (s: DeviceStatus) => void;
|
||||
updateTelemetry: (data: {
|
||||
batteryVolt?: number;
|
||||
temperature?: number;
|
||||
gpsStatus?: number;
|
||||
@@ -42,18 +58,30 @@ interface DeviceState {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const useDeviceStore = create<DeviceState>((set) => ({
|
||||
config: DEFAULT_CONFIG,
|
||||
deviceStatus: 'idle',
|
||||
batteryVolt: 0,
|
||||
temperature: 0,
|
||||
gpsStatus: 0,
|
||||
sdStatus: 0,
|
||||
frameId: 0,
|
||||
accCount: 0,
|
||||
setConfig: (config) => set({ config }),
|
||||
updateConfig: (partial) =>
|
||||
set((s) => ({ config: { ...s.config, ...partial } })),
|
||||
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
|
||||
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
|
||||
}));
|
||||
export const useDeviceStore = create<DeviceState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: DEFAULT_CONFIG,
|
||||
gateConfig: DEFAULT_GATE_CONFIG,
|
||||
configDirty: false,
|
||||
deviceStatus: 'idle',
|
||||
batteryVolt: 0,
|
||||
temperature: 0,
|
||||
gpsStatus: 0,
|
||||
sdStatus: 0,
|
||||
frameId: 0,
|
||||
accCount: 0,
|
||||
setConfig: (config) => set({ config, configDirty: true }),
|
||||
updateConfig: (partial) => set((s) => ({ config: { ...s.config, ...partial }, configDirty: true })),
|
||||
setGateConfig: (gateConfig) => set({ gateConfig }),
|
||||
setConfigDirty: (configDirty) => set({ configDirty }),
|
||||
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
|
||||
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
|
||||
}),
|
||||
{
|
||||
name: 'device-config',
|
||||
storage: createJSONStorage(() => fileStorage),
|
||||
partialize: (state) => ({ config: state.config, gateConfig: state.gateConfig }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
const BASE = FileSystem.documentDirectory ?? '';
|
||||
|
||||
// Async key-value storage backed by individual JSON files.
|
||||
// Compatible with zustand/middleware createJSONStorage().
|
||||
export const fileStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
try {
|
||||
const path = `${BASE}zs_${name}.json`;
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
if (!info.exists) return null;
|
||||
return await FileSystem.readAsStringAsync(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
try {
|
||||
const path = `${BASE}zs_${name}.json`;
|
||||
await FileSystem.writeAsStringAsync(path, value);
|
||||
} catch { /* silent — don't crash on storage failure */ }
|
||||
},
|
||||
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
try {
|
||||
const path = `${BASE}zs_${name}.json`;
|
||||
await FileSystem.deleteAsync(path, { idempotent: true });
|
||||
} catch { /* silent */ }
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user