init
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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' }),
|
||||
}));
|
||||
@@ -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())}`
|
||||
);
|
||||
}
|
||||
@@ -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 })),
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user