This commit is contained in:
2026-06-19 22:08:10 +08:00
parent 091b358705
commit 84590d5477
37 changed files with 5844 additions and 900 deletions
+29 -22
View File
@@ -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 },
});
+48 -35
View File
@@ -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
View File
@@ -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 },
});
+164 -92
View File
@@ -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 },
});
+120
View File
@@ -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',
},
});
+277
View File
@@ -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 },
});
+33
View File
@@ -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;
+8 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+203
View File
@@ -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;
}
+23 -6
View File
@@ -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 ──────────────────────────────────────────────────────────────
+497
View File
@@ -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,
};
}
+101
View File
@@ -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`;
}
+35 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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 }),
},
),
);
+32
View File
@@ -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 */ }
},
};