This commit is contained in:
2026-06-06 22:13:00 +08:00
parent ed8682a1e2
commit 091b358705
27 changed files with 3001 additions and 102 deletions
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { CHANNEL_COLORS } from '../protocol/constants';
interface Props {
maxChannels: number;
visible: boolean[];
onToggle: (idx: number) => void;
}
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
return (
<View style={styles.row}>
{Array.from({ length: maxChannels }, (_, i) => (
<TouchableOpacity
key={i}
style={[styles.chip, { borderColor: CHANNEL_COLORS[i], opacity: visible[i] ? 1 : 0.35 }]}
onPress={() => onToggle(i)}
activeOpacity={0.7}
>
<View style={[styles.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={[styles.label, { color: visible[i] ? CHANNEL_COLORS[i] : '#888' }]}>
CH{i + 1}
</Text>
</TouchableOpacity>
))}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 12, paddingVertical: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 3,
gap: 4,
},
dot: { width: 6, height: 6, borderRadius: 3 },
label: { fontSize: 11, fontWeight: '600' },
});
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
import { formatBattery, formatTemperature, formatCoord } from '../utils/format';
function Indicator({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<View style={styles.indicator}>
<View style={[styles.dot, { backgroundColor: ok === false ? '#ff4444' : ok ? '#44cc44' : '#888888' }]} />
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{value}</Text>
</View>
);
}
export function DeviceStatusBar() {
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
const frame = useDataStore((s) => s.currentFrame);
const meta = frame?.meta;
const lat = meta?.latitude ?? 0;
const lon = meta?.longitude ?? 0;
const hasGps = gpsStatus > 0 || (meta && meta.gpsStatus > 0);
const gpsStr = hasGps && lat ? `${lat.toFixed(5)},${lon.toFixed(5)}` : '--';
const running = deviceStatus === 'running';
const statusColor = running ? '#44cc44' : deviceStatus === 'single' ? '#ffaa00' : '#888888';
const statusLabel = running ? '运行中' : deviceStatus === 'single' ? '单次' : '停止';
return (
<View style={styles.bar}>
<View style={[styles.statusBadge, { borderColor: statusColor }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
<Indicator label="GPS" value={gpsStr} ok={hasGps ? true : false} />
<Indicator label="SD" value={sdStatus > 0 ? 'OK' : '--'} ok={sdStatus > 0} />
<Indicator label="电池" value={batteryVolt ? formatBattery(batteryVolt) : '--'} />
<Indicator label="温度" value={temperature ? formatTemperature(temperature) : '--'} />
</View>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
backgroundColor: '#111',
paddingHorizontal: 12,
paddingVertical: 6,
alignItems: 'center',
flexWrap: 'wrap',
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 6,
paddingVertical: 2,
gap: 4,
},
statusDot: { width: 6, height: 6, borderRadius: 3 },
statusText: { fontSize: 11, fontWeight: '600' },
indicator: { flexDirection: 'row', alignItems: 'center', gap: 3 },
dot: { width: 6, height: 6, borderRadius: 3 },
label: { color: '#888', fontSize: 10 },
value: { color: '#ddd', fontSize: 10, fontWeight: '500' },
});
+235
View File
@@ -0,0 +1,235 @@
import React from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch } from 'react-native';
import { useDeviceStore } from '../stores/deviceStore';
import {
SEND_FREQ_TABLE,
SAMPLE_FREQ_TABLE,
AMP_RATIO_TABLE,
SOURCE_MODE_TABLE,
} from '../protocol/constants';
interface PickerRowProps {
label: string;
options: readonly { code: number; label: string }[];
value: number;
onChange: (code: number) => void;
}
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.optScroll}>
{options.map((o) => (
<TouchableOpacity
key={o.code}
style={[styles.optChip, o.code === value && styles.optChipActive]}
onPress={() => onChange(o.code)}
>
<Text style={[styles.optText, o.code === value && styles.optTextActive]}>
{o.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
interface NumberRowProps {
label: string;
value: number;
onChangeText: (v: string) => void;
suffix?: string;
keyboardType?: 'numeric';
}
function NumberRow({ label, value, onChangeText, suffix, keyboardType = 'numeric' }: NumberRowProps) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={String(value)}
onChangeText={onChangeText}
keyboardType={keyboardType}
selectTextOnFocus
placeholderTextColor="#555"
/>
{suffix && <Text style={styles.suffix}>{suffix}</Text>}
</View>
</View>
);
}
interface FormProps {
onAnyChange?: () => void;
}
export function ParamForm({ onAnyChange }: FormProps = {}) {
const { config, updateConfig } = useDeviceStore();
const patch = (partial: Partial<typeof config>) => {
updateConfig(partial);
onAnyChange?.();
};
return (
<View style={styles.container}>
{/* Channel selector */}
<View style={styles.row}>
<Text style={styles.rowLabel}>通道数</Text>
<View style={styles.channelRow}>
{[1, 2, 3, 4, 5, 6].map((n) => (
<TouchableOpacity
key={n}
style={[styles.chChip, config.channelNum === n && styles.chChipActive]}
onPress={() => patch({ channelNum: n })}
>
<Text style={[styles.chText, config.channelNum === n && styles.chTextActive]}>
{n}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<PickerRow
label="发射频率"
options={SEND_FREQ_TABLE}
value={config.sendFreq}
onChange={(v) => patch({ sendFreq: v })}
/>
<PickerRow
label="采样频率"
options={SAMPLE_FREQ_TABLE}
value={config.sampleFreq}
onChange={(v) => patch({ sampleFreq: v })}
/>
<NumberRow
label="采样点数"
value={config.sampleDepth}
onChangeText={(v) => patch({ sampleDepth: parseInt(v, 10) || 0 })}
/>
<NumberRow
label="叠加次数"
value={config.accNum}
onChangeText={(v) => patch({ accNum: parseInt(v, 10) || 0 })}
/>
<PickerRow
label="增益"
options={AMP_RATIO_TABLE}
value={config.ampRatio}
onChange={(v) => patch({ ampRatio: v })}
/>
<PickerRow
label="源模式"
options={SOURCE_MODE_TABLE}
value={config.sourceMode}
onChange={(v) => patch({ sourceMode: v })}
/>
{/* Reverse accumulation flags */}
<View style={styles.row}>
<Text style={styles.rowLabel}>反向叠加</Text>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>CH1-3</Text>
<Switch
value={config.negAcc123}
onValueChange={(v) => patch({ negAcc123: v })}
thumbColor={config.negAcc123 ? '#4a9eff' : '#888'}
trackColor={{ false: '#333', true: '#1a4a88' }}
/>
<Text style={styles.switchLabel}>CH4-6</Text>
<Switch
value={config.negAcc456}
onValueChange={(v) => patch({ negAcc456: v })}
thumbColor={config.negAcc456 ? '#4a9eff' : '#888'}
trackColor={{ false: '#333', true: '#1a4a88' }}
/>
</View>
</View>
<NumberRow
label="补偿延时"
value={config.compDisableDelay}
onChangeText={(v) => patch({ compDisableDelay: parseInt(v, 10) || 0 })}
suffix="×50μs"
/>
{/* File prefix */}
<View style={styles.row}>
<Text style={styles.rowLabel}>文件前缀</Text>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={config.filePrefix}
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
maxLength={15}
autoCapitalize="none"
placeholderTextColor="#555"
/>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { backgroundColor: '#111' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#2a2a2a',
gap: 10,
},
rowLabel: { color: '#aaa', fontSize: 13, width: 68, flexShrink: 0 },
optScroll: { flex: 1 },
optChip: {
borderWidth: 1,
borderColor: '#444',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 3,
marginRight: 6,
},
optChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
optText: { color: '#888', fontSize: 11 },
optTextActive: { color: '#4a9eff', fontWeight: '600' },
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
input: {
flex: 1,
backgroundColor: '#1e1e1e',
color: '#eee',
fontSize: 13,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 4,
borderWidth: 1,
borderColor: '#333',
},
suffix: { color: '#666', fontSize: 11, marginLeft: 6 },
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
chChip: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#444',
alignItems: 'center',
justifyContent: 'center',
},
chChipActive: { borderColor: '#4a9eff', backgroundColor: '#0d2b55' },
chText: { color: '#888', fontSize: 12, fontWeight: '600' },
chTextActive: { color: '#4a9eff' },
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
switchLabel: { color: '#888', fontSize: 12 },
});
+153
View File
@@ -0,0 +1,153 @@
import React, { useMemo } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
import { CHANNEL_COLORS } from '../protocol/constants';
import type { WaveformData } from '../hooks/useWaveform';
interface Props {
data: WaveformData;
visibleChannels: boolean[];
width: number;
height: number;
}
const PADDING = { top: 12, right: 12, bottom: 32, left: 52 };
const Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
export function WaveformChart({ data, visibleChannels, width, height }: Props) {
const plotW = width - PADDING.left - PADDING.right;
const plotH = height - PADDING.top - PADDING.bottom;
const yMin = data.minLogUV - 0.5;
const yMax = data.maxLogUV + 0.5;
const yRange = yMax - yMin;
// Map log10(absVal) → canvas y (top=high, bottom=low)
const toY = (uv: number): number => {
const absV = Math.abs(uv);
if (absV < 1e-12) return PADDING.top + plotH;
const logV = Math.log10(absV);
return PADDING.top + plotH - ((logV - yMin) / yRange) * plotH;
};
// Map sample index → canvas x
const toX = (i: number, total: number): number =>
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
// Y-axis grid lines and labels
const yGridLines = useMemo(() => {
return Y_TICKS.filter((v) => {
const log = Math.log10(v);
return log >= yMin && log <= yMax;
});
}, [yMin, yMax]);
// Channel paths
const channelPaths = useMemo(() => {
return data.channels.map((ch, idx) => {
if (!visibleChannels[idx]) return null;
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < ch.length; i++) {
const x = toX(i, ch.length);
const y = toY(ch[i]);
if (!isFinite(y) || Math.abs(ch[i]) < 1e-12) continue;
if (!moved) {
path.moveTo(x, y);
moved = true;
} else {
path.lineTo(x, y);
}
}
return path;
});
}, [data, visibleChannels, plotW, plotH, yMin, yRange]);
return (
<View style={[styles.container, { width, height }]}>
<Canvas style={{ width, height }}>
{/* Background */}
{/* Y-axis grid lines */}
{yGridLines.map((v) => {
const y = toY(v);
return (
<Line
key={v}
p1={vec(PADDING.left, y)}
p2={vec(PADDING.left + plotW, y)}
color="#333333"
strokeWidth={0.5}
/>
);
})}
{/* X-axis baseline */}
<Line
p1={vec(PADDING.left, PADDING.top + plotH)}
p2={vec(PADDING.left + plotW, PADDING.top + plotH)}
color="#666666"
strokeWidth={1}
/>
{/* Channel waveform paths */}
{channelPaths.map((path, idx) => {
if (!path || !visibleChannels[idx]) return null;
return (
<Path
key={idx}
path={path}
color={CHANNEL_COLORS[idx]}
style="stroke"
strokeWidth={1.5}
strokeJoin="round"
strokeCap="round"
/>
);
})}
</Canvas>
{/* Y-axis labels (React Native text overlay, positioned absolutely) */}
{yGridLines.map((v) => {
const y = toY(v);
const label = v >= 1000 ? `${v / 1000}k` : v >= 1 ? `${v}` : `${v}`;
return (
<Text
key={v}
style={[styles.axisLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}
>
{label}
</Text>
);
})}
{/* X-axis label */}
<Text style={[styles.xLabel, { top: height - 18, left: PADDING.left }]}>
时间 →
</Text>
{/* Y-axis unit */}
<Text style={[styles.yUnit, { top: 0, left: 0 }]}>μV</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { backgroundColor: '#1a1a1a', position: 'relative' },
axisLabel: {
position: 'absolute',
color: '#aaaaaa',
fontSize: 9,
textAlign: 'right',
},
xLabel: {
position: 'absolute',
color: '#aaaaaa',
fontSize: 9,
},
yUnit: {
position: 'absolute',
color: '#888888',
fontSize: 9,
},
});
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useState } from 'react';
import { Alert } from 'react-native';
import { tcpService } from '../services/TcpService';
import { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop } from '../services/DeviceService';
import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import type { SetupConfig } from '../protocol/types';
export function useDevice() {
const [busy, setBusy] = useState(false);
const connStore = useConnectionStore();
const devStore = useDeviceStore();
const connect = useCallback(() => {
const { host, port, setStatus, setError } = useConnectionStore.getState();
setStatus('connecting');
tcpService.connect(host, port, {
onConnect: () => setStatus('connected'),
onFrame: handleIncomingFrame,
onError: (err) => setError(err.message),
onClose: () => {
if (useConnectionStore.getState().status === 'connected') {
setStatus('reconnecting');
}
},
});
}, []);
const disconnect = useCallback(() => {
tcpService.disconnect();
useConnectionStore.getState().setStatus('disconnected');
useDeviceStore.getState().setDeviceStatus('idle');
}, []);
const setup = useCallback(async (cfg?: SetupConfig) => {
const config = cfg ?? useDeviceStore.getState().config;
setBusy(true);
try {
const ack = await deviceSetup(config);
if (ack.result !== 0x01) {
Alert.alert('配置失败', ack.reason || '设备返回失败');
}
return ack;
} catch (e: any) {
Alert.alert('配置超时', e.message);
} finally {
setBusy(false);
}
}, []);
const startContinuous = useCallback(async () => {
setBusy(true);
try {
const ack = await deviceStartContinuous();
if (ack.result !== 0x01) Alert.alert('启动失败', ack.reason);
return ack;
} catch (e: any) {
Alert.alert('启动超时', e.message);
} finally {
setBusy(false);
}
}, []);
const startSingle = useCallback(async () => {
setBusy(true);
try {
const ack = await deviceStartSingle();
if (ack.result !== 0x01) Alert.alert('单次采集失败', ack.reason);
return ack;
} catch (e: any) {
Alert.alert('单次采集超时', e.message);
} finally {
setBusy(false);
}
}, []);
const stop = useCallback(async () => {
setBusy(true);
try {
await deviceStop();
} catch (e: any) {
Alert.alert('停止超时', e.message);
} finally {
setBusy(false);
}
}, []);
return {
busy,
connected: connStore.status === 'connected',
status: connStore.status,
deviceStatus: devStore.deviceStatus,
connect,
disconnect,
setup,
startContinuous,
startSingle,
stop,
};
}
+82
View File
@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { useDataStore } from '../stores/dataStore';
const MAX_DISPLAY_POINTS = 512; // downsample to this many points for rendering
// Downsample an array using peak-hold (envelope) method
function downsample(data: Float64Array, targetLen: number): Float64Array {
if (data.length <= targetLen) return data;
const ratio = data.length / targetLen;
const out = new Float64Array(targetLen);
for (let i = 0; i < targetLen; i++) {
const start = Math.floor(i * ratio);
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
let maxAbs = 0;
let maxVal = 0;
for (let j = start; j < end; j++) {
if (Math.abs(data[j]) > maxAbs) {
maxAbs = Math.abs(data[j]);
maxVal = data[j];
}
}
out[i] = maxVal;
}
return out;
}
export interface WaveformData {
channels: Float64Array[]; // downsampled μV data per channel
timeMs: Float64Array; // time axis in ms
minLogUV: number; // log10 of min absolute non-zero value
maxLogUV: number; // log10 of max absolute value
sampleDepth: number; // original samples per channel
}
export function useWaveform(visibleChannels: boolean[]): WaveformData | null {
const frame = useDataStore((s) => s.currentFrame);
return useMemo(() => {
if (!frame) return null;
const { adcUV, meta } = frame;
const sampleDepth = adcUV[0]?.length ?? 0;
if (sampleDepth === 0) return null;
// Build time axis in ms using sample freq (approximation)
// We don't have actual sample rate here, so we use index/total
// The real time axis depends on the sendFreq and sampleFreq config
// Use normalised 0–1 for now, App can scale by actual period
const displayLen = Math.min(sampleDepth, MAX_DISPLAY_POINTS);
const timeMs = new Float64Array(displayLen);
for (let i = 0; i < displayLen; i++) {
timeMs[i] = i / displayLen; // normalised 0–1 (multiply by period in ms externally)
}
let globalMin = Infinity;
let globalMax = -Infinity;
const channels: Float64Array[] = adcUV.map((ch, idx) => {
if (!visibleChannels[idx]) return new Float64Array(displayLen);
const ds = downsample(ch, displayLen);
for (let i = 0; i < ds.length; i++) {
const absV = Math.abs(ds[i]);
if (absV > 1e-9) {
globalMin = Math.min(globalMin, absV);
globalMax = Math.max(globalMax, absV);
}
}
return ds;
});
if (!isFinite(globalMin)) globalMin = 1e-3;
if (!isFinite(globalMax)) globalMax = 1e6;
return {
channels,
timeMs,
minLogUV: Math.log10(globalMin),
maxLogUV: Math.log10(globalMax),
sampleDepth,
};
}, [frame, visibleChannels]);
}
+94
View File
@@ -0,0 +1,94 @@
export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
export const FRAME_FLAG_4BYTE = 0xfe;
export const DEFAULT_DEVICE_IP = '192.168.4.1';
export const DEFAULT_TCP_PORT = 4321;
export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit
export const FuncCode = {
// App → Device
SETUP_REQ: 0x01,
CONTINUOUS_REQ: 0x02,
SINGLE_REQ: 0x03,
STOP_REQ: 0x04,
ACTIVE_REQ: 0x05,
SPLITFRAME_REQ: 0x08,
// Device → App
SETUP_ACK: 0x81,
CONTINUOUS_ACK: 0x82,
SINGLE_ACK: 0x83,
STOP_ACK: 0x84,
DATA_ACK: 0x85,
SPLITFRAME_ACK: 0x88,
COMP_DATA_ACK: 0x95,
} as const;
export const AckResult = { OK: 0x01, FAIL: 0x02 } as const;
export const DataChannel = { USB: 0x01, P900: 0x02, WIFI: 0x03 } as const;
export const SplitFrameCmd = { START: 0x01, REQUEST: 0x02, STOP: 0x03, LAST: 0x82 } as const;
export const SEND_FREQ_TABLE = [
{ code: 0x00, label: '0.5 Hz' },
{ code: 0x01, label: '1 Hz' },
{ code: 0x02, label: '2 Hz' },
{ code: 0x03, label: '4 Hz' },
{ code: 0x04, label: '8 Hz' },
{ code: 0x05, label: '12.5 Hz' },
{ code: 0x06, label: '16 Hz' },
{ code: 0x07, label: '25 Hz' },
{ code: 0x08, label: '32 Hz' },
{ code: 0x09, label: '50 Hz' },
{ code: 0x0a, label: '64 Hz' },
// 0xFE (ZTEM) not supported
] as const;
export const SAMPLE_FREQ_TABLE = [
{ code: 0x00, label: '250 kHz' },
{ code: 0x01, label: '125 kHz' },
{ code: 0x02, label: '62.5 kHz' },
{ code: 0x03, label: '31.25 kHz' },
{ code: 0x04, label: '15.6 kHz' },
{ code: 0x05, label: '7.8 kHz' },
{ code: 0x06, label: '3.9 kHz' },
{ code: 0x07, label: '1.95 kHz' },
{ code: 0x08, label: '977 Hz' },
{ code: 0x09, label: '488 Hz' },
{ code: 0x0a, label: '244 Hz' },
{ code: 0x0b, label: '122 Hz' },
{ code: 0x0c, label: '61 Hz' },
] as const;
// Gain table: ampRatio code → multiplier
export const AMP_GAIN = [0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 1];
export const AMP_RATIO_TABLE = [
{ code: 0x00, label: '1/8×', gain: 0.125 },
{ code: 0x01, label: '1/4×', gain: 0.25 },
{ code: 0x02, label: '1/2×', gain: 0.5 },
{ code: 0x03, label: '1×', gain: 1 },
{ code: 0x04, label: '2×', gain: 2 },
{ code: 0x05, label: '4×', gain: 4 },
{ code: 0x06, label: '8×', gain: 8 },
{ code: 0x07, label: '16×', gain: 16 },
{ code: 0x08, label: '32×', gain: 32 },
{ code: 0x09, label: '64×', gain: 64 },
{ code: 0x0a, label: '128×', gain: 128 },
{ code: 0x0b, label: '自动', gain: 1 },
] as const;
export const SOURCE_MODE_TABLE = [
{ code: 0x00, label: '单源 A', sourceNum: 1 },
{ code: 0x03, label: '双源 A-B', sourceNum: 2 },
{ code: 0x04, label: '双源 A-C', sourceNum: 2 },
{ code: 0x06, label: '三源 A-B-C', sourceNum: 3 },
{ code: 0x07, label: '磁源 A', sourceNum: 1 },
] as const;
export const CHANNEL_COLORS = [
'#FF4444', // CH1 红
'#44BB44', // CH2 绿
'#4444FF', // CH3 蓝
'#FFAA00', // CH4 橙
'#AA44FF', // CH5 紫
'#00CCCC', // CH6 青
] as const;
+97
View File
@@ -0,0 +1,97 @@
import { FRAME_FLAG_4BYTE, FuncCode, DataChannel } from './constants';
import type { SetupConfig } from './types';
const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
// Build the 10-byte packet header
function buildHeader(func: number, payloadLen: number): Uint8Array {
const buf = new Uint8Array(10);
buf.set(HEADER_MAGIC, 0);
buf[4] = FRAME_FLAG_4BYTE;
buf[5] = func;
buf[6] = payloadLen & 0xff;
buf[7] = (payloadLen >> 8) & 0xff;
buf[8] = (payloadLen >> 16) & 0xff;
buf[9] = (payloadLen >> 24) & 0xff;
return buf;
}
// Build a command packet with a zero-filled 54-byte payload (minimum)
function buildSimpleCommand(func: number): Uint8Array {
const payload = new Uint8Array(54);
const header = buildHeader(func, 54);
const pkt = new Uint8Array(10 + 54);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}
// Write a little-endian uint16 into buf at offset
function writeUint16LE(buf: Uint8Array, offset: number, value: number) {
buf[offset] = value & 0xff;
buf[offset + 1] = (value >> 8) & 0xff;
}
// Write a little-endian uint32 into buf at offset
function writeUint32LE(buf: Uint8Array, offset: number, value: number) {
buf[offset] = value & 0xff;
buf[offset + 1] = (value >> 8) & 0xff;
buf[offset + 2] = (value >> 16) & 0xff;
buf[offset + 3] = (value >> 24) & 0xff;
}
// Write ASCII string (null-padded) into buf starting at offset
function writeString(buf: Uint8Array, offset: number, str: string, maxLen: number) {
for (let i = 0; i < maxLen; i++) {
buf[offset + i] = i < str.length ? str.charCodeAt(i) & 0xff : 0;
}
}
export function buildSetupPacket(cfg: SetupConfig): Uint8Array {
const PAYLOAD_LEN = 64; // sizeof(setupReq_t)
const payload = new Uint8Array(PAYLOAD_LEN);
payload[0] = cfg.channelNum & 0xff;
payload[1] = cfg.sendFreq & 0xff;
payload[2] = cfg.sampleFreq & 0xff;
writeUint16LE(payload, 3, cfg.sampleDepth);
// accNum is 14-bit; bit1 = negAcc456; bit0 = negAcc123
let accFlags = (cfg.accNum & 0x3fff) << 2;
if (cfg.negAcc456) accFlags |= 0x02;
if (cfg.negAcc123) accFlags |= 0x01;
writeUint16LE(payload, 5, accFlags);
payload[7] = cfg.ampRatio & 0xff;
payload[8] = cfg.dataChannel & 0xff;
payload[9] = cfg.compRes & 0xff;
payload[10] = cfg.secondSampleFreq & 0xff;
writeUint16LE(payload, 11, cfg.compDisableDelay);
payload[13] = cfg.sourceMode & 0xff;
writeUint16LE(payload, 14, cfg.batteryVoltageMin);
// bytes 16-37: reserved (zeros)
writeString(payload, 38, cfg.filePrefix, 16);
const header = buildHeader(FuncCode.SETUP_REQ, PAYLOAD_LEN);
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}
export const buildContinuousReq = () => buildSimpleCommand(FuncCode.CONTINUOUS_REQ);
export const buildSingleReq = () => buildSimpleCommand(FuncCode.SINGLE_REQ);
export const buildStopReq = () => buildSimpleCommand(FuncCode.STOP_REQ);
export const buildActiveReq = () => buildSimpleCommand(FuncCode.ACTIVE_REQ);
export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array {
const PAYLOAD_LEN = 54;
const payload = new Uint8Array(PAYLOAD_LEN);
payload[0] = subCmd & 0xff;
writeUint16LE(payload, 1, frameNo);
const header = buildHeader(FuncCode.SPLITFRAME_REQ, PAYLOAD_LEN);
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
pkt.set(header, 0);
pkt.set(payload, 10);
return pkt;
}
+178
View File
@@ -0,0 +1,178 @@
import { MAX_PAYLOAD_SIZE } from './constants';
import type { RawFrame, MeasurementMeta, MeasurementFrame, AckPacket } from './types';
import { FuncCode, AMP_GAIN } from './constants';
// Streaming TCP frame parser.
// Handles packet fragmentation (TCP splits large packets across multiple reads).
export class TemFrameParser {
private buf: Uint8Array;
private len = 0;
private readonly capacity: number;
constructor(capacity = 2 * 1024 * 1024) {
this.capacity = capacity;
this.buf = new Uint8Array(capacity);
}
feed(chunk: Uint8Array | ArrayBuffer | { buffer: ArrayBuffer; byteOffset: number; byteLength: number }): RawFrame[] {
let incoming: Uint8Array;
if (chunk instanceof Uint8Array) {
incoming = chunk;
} else if (chunk instanceof ArrayBuffer) {
incoming = new Uint8Array(chunk);
} else {
incoming = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
}
if (this.len + incoming.length > this.capacity) {
// Buffer overflow — reset and resync
this.len = 0;
}
this.buf.set(incoming, this.len);
this.len += incoming.length;
const results: RawFrame[] = [];
let offset = 0;
while (offset < this.len) {
const magic = this.findMagic(offset);
if (magic === -1) {
// No header found — keep last 3 bytes in case they start a header
const keep = Math.min(3, this.len - offset);
this.buf.copyWithin(0, this.len - keep);
this.len = keep;
return results;
}
offset = magic;
// Wait until we have the full 10-byte header
if (this.len - offset < 10) break;
const flag = this.buf[offset + 4];
const func = this.buf[offset + 5];
const payloadLen = this.readUint32LE(offset + 6);
if (payloadLen > MAX_PAYLOAD_SIZE) {
// Bogus length — skip this magic and search again
offset += 4;
continue;
}
const totalLen = 10 + payloadLen;
if (this.len - offset < totalLen) break; // wait for more data
const payload = new Uint8Array(totalLen - 10);
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
results.push({ flag, func, payload });
offset += totalLen;
}
// Compact — move remaining bytes to front
if (offset > 0 && offset <= this.len) {
this.buf.copyWithin(0, offset, this.len);
this.len -= offset;
}
return results;
}
reset() {
this.len = 0;
}
private findMagic(start: number): number {
const b = this.buf;
const end = this.len - 3;
for (let i = start; i <= end; i++) {
if (b[i] === 0x68 && b[i + 1] === 0x68 && b[i + 2] === 0xff && b[i + 3] === 0xff) {
return i;
}
}
return -1;
}
private readUint32LE(offset: number): number {
const b = this.buf;
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
}
}
// Parse the metadata header of a 0x85 or 0x95 DATA_ACK payload
export function parseMetadata(payload: Uint8Array): MeasurementMeta | null {
if (payload.length < 54) return null;
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
let o = 0;
const devId = view.getUint8(o++);
const utc = view.getUint32(o, true); o += 4;
const longitude = view.getFloat64(o, true); o += 8;
const latitude = view.getFloat64(o, true); o += 8;
const altitude = view.getFloat32(o, true); o += 4;
const height = view.getFloat32(o, true); o += 4;
const sdGps = view.getUint8(o++);
const ampRatio = view.getUint8(o++);
const roll = view.getFloat32(o, true); o += 4;
const pitch = view.getFloat32(o, true); o += 4;
const yaw = view.getFloat32(o, true); o += 4;
const channelNum = view.getUint8(o++);
const current = view.getUint32(o, true); o += 4;
const temperature = view.getUint16(o, true); o += 2;
const batteryVolt = view.getUint16(o, true); o += 2;
const sourceMode = view.getUint8(o++);
return {
devId, utc, longitude, latitude, altitude, height,
sdStatus: sdGps & 0x0f,
gpsStatus: (sdGps >> 4) & 0x0f,
ampRatio, roll, pitch, yaw, channelNum,
current, temperature, batteryVolt, sourceMode,
};
}
// Parse ADC sample data from a 0x85/0x95 payload into per-channel arrays
export function parseAdcData(
payload: Uint8Array,
channelNum: number,
accNum: number,
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
const META_SIZE = 54;
const sampleDataLen = payload.length - META_SIZE;
const totalSamples = sampleDataLen / 4; // int32 per sample
const samplesPerChannel = Math.floor(totalSamples / channelNum);
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
const ampRatio = payload[9]; // ampRatio in metadata at offset 9 from payload start...
// actually re-derive from parseMetadata
const gain = AMP_GAIN[payload[9 + 1]] ?? 1; // byte 10 of payload (after devId+utc+lon+lat+alt+height+sdGps = 1+4+8+8+4+4+1 = 30)
// Correct: ampRatio is at offset 31 in payload (1+4+8+8+4+4+1 = 30, then ampRatio at 30)
const correctGain = AMP_GAIN[payload[30]] ?? 1;
const adcRaw: Int32Array[] = [];
const adcUV: Float64Array[] = [];
for (let ch = 0; ch < channelNum; ch++) {
const raw = new Int32Array(samplesPerChannel);
const uv = new Float64Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
const idx = (ch * samplesPerChannel + i) * 4;
const val = view.getInt32(idx, true);
raw[i] = val;
uv[i] = val / (accNum || 1) / correctGain;
}
adcRaw.push(raw);
adcUV.push(uv);
}
return { adcRaw, adcUV };
}
// Parse a generic ACK packet (0x81–0x84)
export function parseAck(payload: Uint8Array): AckPacket {
const result = payload[0] ?? 0x02;
let reason = '';
// reason string starts at offset 22 in the payload, max 32 chars
const reasonOffset = 22;
for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) {
if (payload[i] === 0) break;
reason += String.fromCharCode(payload[i]);
}
return { result, reason };
}
+83
View File
@@ -0,0 +1,83 @@
// Raw parsed frame from the TCP stream
export interface RawFrame {
flag: number;
func: number;
payload: Uint8Array;
}
// Setup configuration sent to device (maps to setupReq_t)
export interface SetupConfig {
channelNum: number; // 1–6
sendFreq: number; // transmitter freq code
sampleFreq: number; // ADC sample freq code
sampleDepth: number; // points per cycle (uint16)
accNum: number; // stack count (14-bit, 0–16383)
negAcc456: boolean; // reverse stack ch4-6
negAcc123: boolean; // reverse stack ch1-3
ampRatio: number; // gain code
dataChannel: number; // 0x01=USB 0x02=P900 0x03=WiFi
compRes: number; // compensation resistor
secondSampleFreq: number; // secondary sample freq code
compDisableDelay: number; // delay × 50μs (uint16)
sourceMode: number; // source mode code
batteryVoltageMin: number;// min battery voltage (uint16)
filePrefix: string; // SD file prefix, max 15 chars
}
// Generic ACK from device
export interface AckPacket {
result: number; // 0x01=OK 0x02=FAIL
reason: string;
}
// Measurement metadata from device (first 54 bytes of 0x85 payload)
export interface MeasurementMeta {
devId: number;
utc: number; // seconds since epoch
longitude: number; // degrees
latitude: number; // degrees
altitude: number; // meters
height: number;
sdStatus: number; // bit[3:0]
gpsStatus: number; // bit[7:4] >> 4
ampRatio: number;
roll: number; // degrees
pitch: number; // degrees
yaw: number; // degrees
channelNum: number;
current: number; // peak TX current (raw)
temperature: number; // raw ADC
batteryVolt: number; // raw ADC
sourceMode: number;
}
// A complete measurement frame (single or multi-source)
export interface MeasurementFrame {
meta: MeasurementMeta;
// channelNum × sampleDepth matrix, indexed [channel][sample]
adcRaw: Int32Array[];
// Converted to μV: raw / accNum / gain
adcUV: Float64Array[];
accNum: number; // stack count at time of reception
gain: number; // gain multiplier at time of reception
timestamp: number;// local ms timestamp when received
frameId: number; // sequential frame counter
}
// Split frame session state
export interface SplitFrameSession {
active: boolean;
frameNo: number;
chunks: Uint8Array[];
}
// Connection state values
export type ConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'error';
// Device running state
export type DeviceStatus = 'idle' | 'running' | 'single';
+167
View File
@@ -0,0 +1,167 @@
import { tcpService } from './TcpService';
import {
buildSetupPacket,
buildContinuousReq,
buildSingleReq,
buildStopReq,
buildSplitFrameReq,
} from '../protocol/packet';
import {
parseMetadata,
parseAdcData,
parseAck,
} from '../protocol/parser';
import { FuncCode, SplitFrameCmd, AckResult } from '../protocol/constants';
import type { RawFrame, SetupConfig, MeasurementFrame, AckPacket } from '../protocol/types';
import { useConnectionStore } from '../stores/connectionStore';
import { useDeviceStore } from '../stores/deviceStore';
import { useDataStore } from '../stores/dataStore';
let frameCounter = 0;
// Pending ACK promise resolver keyed by func code
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
// Split frame accumulation state
let splitChunks: Uint8Array[] = [];
let splitActive = false;
let splitFrameNo = 0;
export function handleIncomingFrame(frame: RawFrame) {
const { func, payload } = frame;
const devStore = useDeviceStore.getState();
const dataStore = useDataStore.getState();
switch (func) {
// ── ACK responses ──────────────────────────────────────────────
case FuncCode.SETUP_ACK:
case FuncCode.CONTINUOUS_ACK:
case FuncCode.SINGLE_ACK:
case FuncCode.STOP_ACK: {
const ack = parseAck(payload);
const resolver = pendingAcks.get(func);
if (resolver) {
pendingAcks.delete(func);
resolver(ack);
}
if (func === FuncCode.CONTINUOUS_ACK && ack.result === AckResult.OK) {
devStore.setDeviceStatus('running');
}
if (func === FuncCode.SINGLE_ACK && ack.result === AckResult.OK) {
devStore.setDeviceStatus('single');
}
if (func === FuncCode.STOP_ACK) {
devStore.setDeviceStatus('idle');
}
break;
}
// ── Measurement data (single or multi-source) ──────────────────
case FuncCode.DATA_ACK:
case FuncCode.COMP_DATA_ACK: {
processMeasurementPayload(payload, devStore, dataStore);
break;
}
// ── Split frame response ───────────────────────────────────────
case FuncCode.SPLITFRAME_ACK: {
const subCmd = payload[0];
// Data starts at byte 3 (subCmd + frameNo uint16)
const chunk = payload.slice(3);
splitChunks.push(chunk);
if (subCmd === SplitFrameCmd.LAST) {
// Reassemble and process
const total = splitChunks.reduce((s, c) => s + c.length, 0);
const assembled = new Uint8Array(total);
let off = 0;
for (const c of splitChunks) {
assembled.set(c, off);
off += c.length;
}
splitChunks = [];
splitActive = false;
processMeasurementPayload(assembled, devStore, dataStore);
} else {
// Request next frame
splitFrameNo++;
tcpService.send(buildSplitFrameReq(SplitFrameCmd.REQUEST, splitFrameNo));
}
break;
}
}
}
function processMeasurementPayload(
payload: Uint8Array,
devStore: ReturnType<typeof useDeviceStore.getState>,
dataStore: ReturnType<typeof useDataStore.getState>,
) {
const meta = parseMetadata(payload);
if (!meta) return;
const cfg = devStore.config;
const accNum = cfg.accNum || 1;
const { adcRaw, adcUV } = parseAdcData(payload, meta.channelNum, accNum);
const measurementFrame: MeasurementFrame = {
meta,
adcRaw,
adcUV,
accNum,
gain: cfg.ampRatio,
timestamp: Date.now(),
frameId: frameCounter++,
};
dataStore.addFrame(measurementFrame);
}
// ── Public API ──────────────────────────────────────────────────────────────
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingAcks.delete(ackFunc);
reject(new Error('ACK timeout'));
}, timeoutMs);
pendingAcks.set(ackFunc, (ack) => {
clearTimeout(timer);
resolve(ack);
});
const sent = tcpService.send(pkt);
if (!sent) {
clearTimeout(timer);
pendingAcks.delete(ackFunc);
reject(new Error('TCP not connected'));
}
});
}
export async function deviceSetup(config: SetupConfig): Promise<AckPacket> {
useDeviceStore.getState().setConfig(config);
return sendAndWaitAck(buildSetupPacket(config), FuncCode.SETUP_ACK);
}
export async function deviceStartContinuous(): Promise<AckPacket> {
return sendAndWaitAck(buildContinuousReq(), FuncCode.CONTINUOUS_ACK);
}
export async function deviceStartSingle(): Promise<AckPacket> {
return sendAndWaitAck(buildSingleReq(), FuncCode.SINGLE_ACK);
}
export async function deviceStop(): Promise<AckPacket> {
return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK);
}
// Initiate split frame transfer to request large data
export function deviceStartSplitFrame() {
if (splitActive) return;
splitActive = true;
splitFrameNo = 0;
splitChunks = [];
tcpService.send(buildSplitFrameReq(SplitFrameCmd.START, 0));
}
+108
View File
@@ -0,0 +1,108 @@
import TcpSocket from 'react-native-tcp-socket';
import { TemFrameParser } from '../protocol/parser';
import type { RawFrame } from '../protocol/types';
export type TcpCallbacks = {
onConnect: () => void;
onFrame: (frame: RawFrame) => void;
onError: (err: Error) => void;
onClose: () => void;
};
class TcpService {
private socket: ReturnType<typeof TcpSocket.createConnection> | null = null;
private parser = new TemFrameParser();
private callbacks: TcpCallbacks | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectEnabled = false;
private host = '';
private port = 0;
connect(host: string, port: number, callbacks: TcpCallbacks) {
this.host = host;
this.port = port;
this.callbacks = callbacks;
this.reconnectEnabled = true;
this.parser.reset();
this.createSocket();
}
private createSocket() {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
const sock = TcpSocket.createConnection(
{ host: this.host, port: this.port, tls: false },
() => {
this.connected = true;
clearTimeout(this.reconnectTimer!);
this.callbacks?.onConnect();
},
);
sock.on('data', (data: any) => {
const arr: Uint8Array =
data instanceof Uint8Array
? data
: data instanceof ArrayBuffer
? new Uint8Array(data)
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const frames = this.parser.feed(arr);
frames.forEach((f) => this.callbacks?.onFrame(f));
});
sock.on('error', (err: Error) => {
this.connected = false;
this.callbacks?.onError(err);
this.scheduleReconnect();
});
sock.on('close', () => {
this.connected = false;
this.callbacks?.onClose();
this.scheduleReconnect();
});
this.socket = sock;
}
private scheduleReconnect() {
if (!this.reconnectEnabled) return;
clearTimeout(this.reconnectTimer!);
this.reconnectTimer = setTimeout(() => {
if (this.reconnectEnabled) {
this.parser.reset();
this.createSocket();
}
}, 3000);
}
send(data: Uint8Array): boolean {
if (!this.connected || !this.socket) return false;
try {
this.socket.write(data as unknown as string);
return true;
} catch {
return false;
}
}
disconnect() {
this.reconnectEnabled = false;
clearTimeout(this.reconnectTimer!);
this.socket?.destroy();
this.socket = null;
this.connected = false;
this.parser.reset();
}
isConnected() {
return this.connected;
}
}
// Singleton instance shared across the app
export const tcpService = new TcpService();
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { DEFAULT_DEVICE_IP, DEFAULT_TCP_PORT } from '../protocol/constants';
import type { ConnectionStatus } from '../protocol/types';
interface ConnectionState {
status: ConnectionStatus;
host: string;
port: number;
lastError: string;
setStatus: (s: ConnectionStatus) => void;
setHost: (h: string) => void;
setPort: (p: number) => void;
setError: (e: string) => void;
}
export const useConnectionStore = create<ConnectionState>((set) => ({
status: 'disconnected',
host: DEFAULT_DEVICE_IP,
port: DEFAULT_TCP_PORT,
lastError: '',
setStatus: (status) => set({ status }),
setHost: (host) => set({ host }),
setPort: (port) => set({ port }),
setError: (lastError) => set({ lastError, status: 'error' }),
}));
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import type { MeasurementFrame } from '../protocol/types';
const MAX_HISTORY = 500; // keep last 500 frames in memory
interface DataState {
currentFrame: MeasurementFrame | null;
history: MeasurementFrame[]; // newest first
sessionId: string;
addFrame: (frame: MeasurementFrame) => void;
clearHistory: () => void;
newSession: () => void;
}
export const useDataStore = create<DataState>((set) => ({
currentFrame: null,
history: [],
sessionId: generateSessionId(),
addFrame: (frame) =>
set((s) => ({
currentFrame: frame,
history: [frame, ...s.history].slice(0, MAX_HISTORY),
})),
clearHistory: () => set({ history: [], currentFrame: null }),
newSession: () => set({ sessionId: generateSessionId(), history: [], currentFrame: null }),
}));
function generateSessionId(): string {
const now = new Date();
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand';
import type { SetupConfig, DeviceStatus } from '../protocol/types';
import { DataChannel } from '../protocol/constants';
const DEFAULT_CONFIG: SetupConfig = {
channelNum: 3,
sendFreq: 0x03, // 4 Hz
sampleFreq: 0x03, // 31.25 kHz
sampleDepth: 1024,
accNum: 50,
negAcc456: false,
negAcc123: false,
ampRatio: 0x03, // 1×
dataChannel: DataChannel.WIFI,
compRes: 0,
secondSampleFreq: 0x03,
compDisableDelay: 300, // 300 × 50μs = 15ms
sourceMode: 0x00, // single source A
batteryVoltageMin: 0,
filePrefix: 'TEM',
};
interface DeviceState {
config: SetupConfig;
deviceStatus: DeviceStatus;
batteryVolt: number; // raw ADC
temperature: number; // raw ADC
gpsStatus: number;
sdStatus: number;
frameId: number; // last received frame ID
accCount: number; // stacking progress
setConfig: (cfg: SetupConfig) => void;
updateConfig: (partial: Partial<SetupConfig>) => void;
setDeviceStatus: (s: DeviceStatus) => void;
updateTelemetry: (data: {
batteryVolt?: number;
temperature?: number;
gpsStatus?: number;
sdStatus?: number;
frameId?: number;
accCount?: number;
}) => void;
}
export const useDeviceStore = create<DeviceState>((set) => ({
config: DEFAULT_CONFIG,
deviceStatus: 'idle',
batteryVolt: 0,
temperature: 0,
gpsStatus: 0,
sdStatus: 0,
frameId: 0,
accCount: 0,
setConfig: (config) => set({ config }),
updateConfig: (partial) =>
set((s) => ({ config: { ...s.config, ...partial } })),
setDeviceStatus: (deviceStatus) => set({ deviceStatus }),
updateTelemetry: (data) => set((s) => ({ ...s, ...data })),
}));
+105
View File
@@ -0,0 +1,105 @@
// Use legacy API for documentDirectory compatibility
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import type { MeasurementFrame } from '../protocol/types';
import { formatUtc, formatCoord } from './format';
const DATA_DIR = (FileSystem.documentDirectory ?? '') + 'tem_data/';
async function ensureDir() {
const info = await FileSystem.getInfoAsync(DATA_DIR);
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
}
// Build CSV content for a list of frames
function framesToCsv(frames: MeasurementFrame[]): string {
if (frames.length === 0) return '';
const channelNum = frames[0].meta.channelNum;
const chHeaders = Array.from({ length: channelNum }, (_, i) => `CH${i + 1}_peak_uV`).join(',');
const header = `FrameID,UTC,Longitude,Latitude,Altitude,GPS,SD,AmpRatio,Current,Temp,Battery,Roll,Pitch,Yaw,${chHeaders}`;
const rows = frames.map((f) => {
const m = f.meta;
const peaks = f.adcUV.map((ch) => {
const maxAbs = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
return maxAbs.toFixed(4);
});
return [
f.frameId,
formatUtc(m.utc),
m.longitude.toFixed(7),
m.latitude.toFixed(7),
m.altitude.toFixed(2),
m.gpsStatus,
m.sdStatus,
m.ampRatio,
m.current,
m.temperature,
m.batteryVolt,
m.roll.toFixed(2),
m.pitch.toFixed(2),
m.yaw.toFixed(2),
...peaks,
].join(',');
});
return [header, ...rows].join('\n');
}
export async function exportCsv(frames: MeasurementFrame[], sessionId: string): Promise<string> {
await ensureDir();
const filename = `TEM_${sessionId}.csv`;
const path = DATA_DIR + filename;
const csv = framesToCsv(frames);
await FileSystem.writeAsStringAsync(path, csv, { encoding: FileSystem.EncodingType.UTF8 });
return path;
}
// Save a single frame's raw ADC data as binary file
export async function saveFrameBin(frame: MeasurementFrame, sessionId: string): Promise<string> {
await ensureDir();
const filename = `${sessionId}_f${String(frame.frameId).padStart(6, '0')}.bin`;
const path = DATA_DIR + filename;
const channelNum = frame.adcRaw.length;
const samplesPerCh = frame.adcRaw[0]?.length ?? 0;
// Header: magic(4) + channelNum(4) + samplesPerCh(4) = 12 bytes
const totalBytes = 12 + channelNum * samplesPerCh * 4;
const buf = new ArrayBuffer(totalBytes);
const view = new DataView(buf);
// Magic: "TEM\x01"
view.setUint8(0, 0x54); view.setUint8(1, 0x45); view.setUint8(2, 0x4d); view.setUint8(3, 0x01);
view.setUint32(4, channelNum, true);
view.setUint32(8, samplesPerCh, true);
let offset = 12;
for (const ch of frame.adcRaw) {
for (let i = 0; i < ch.length; i++) {
view.setInt32(offset, ch[i], true);
offset += 4;
}
}
// Convert ArrayBuffer to base64 string for expo-file-system
const uint8 = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
const b64 = btoa(binary);
await FileSystem.writeAsStringAsync(path, b64, { encoding: FileSystem.EncodingType.Base64 });
return path;
}
export async function shareFile(filePath: string) {
const canShare = await Sharing.isAvailableAsync();
if (canShare) {
await Sharing.shareAsync(filePath);
}
}
export async function listSessionFiles(sessionId: string): Promise<string[]> {
await ensureDir();
const all = await FileSystem.readDirectoryAsync(DATA_DIR);
return all.filter((f) => f.startsWith(sessionId)).map((f) => DATA_DIR + f);
}
+51
View File
@@ -0,0 +1,51 @@
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
export function formatUV(uv: number): string {
const abs = Math.abs(uv);
if (abs >= 1e6) return `${(uv / 1e6).toFixed(2)} V`;
if (abs >= 1e3) return `${(uv / 1e3).toFixed(2)} mV`;
if (abs >= 1) return `${uv.toFixed(2)} μV`;
if (abs >= 1e-3) return `${(uv * 1e3).toFixed(2)} nV`;
return `${uv.toExponential(2)} μV`;
}
export function formatCoord(deg: number, isLon: boolean): string {
if (!isFinite(deg)) return '--';
const dir = isLon ? (deg >= 0 ? 'E' : 'W') : deg >= 0 ? 'N' : 'S';
return `${Math.abs(deg).toFixed(6)}°${dir}`;
}
export function formatUtc(utcSec: number): string {
if (!utcSec) return '--';
const d = new Date(utcSec * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
export function formatBattery(raw: number): string {
// raw ADC → voltage conversion depends on hardware; placeholder
const v = (raw / 4096) * 3.3 * 10; // rough estimate
return `${v.toFixed(1)} V`;
}
export function formatTemperature(raw: number): string {
// placeholder conversion
const c = raw / 10;
return `${c.toFixed(1)} °C`;
}
export function sendFreqLabel(code: number): string {
return SEND_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function sampleFreqLabel(code: number): string {
return SAMPLE_FREQ_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function ampRatioLabel(code: number): string {
return AMP_RATIO_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}
export function sourceModeLabel(code: number): string {
return SOURCE_MODE_TABLE.find((t) => t.code === code)?.label ?? `0x${code.toString(16)}`;
}