200 lines
6.7 KiB
TypeScript
200 lines
6.7 KiB
TypeScript
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;
|
||
onOverflow?: () => void;
|
||
|
||
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) {
|
||
if (__DEV__) console.warn(`[Parser] buffer overflow: ${this.len} + ${incoming.length} > ${this.capacity}, resetting`);
|
||
this.len = 0;
|
||
this.onOverflow?.();
|
||
}
|
||
this.buf.set(incoming, this.len);
|
||
this.len += incoming.length;
|
||
|
||
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 is2Byte = !!(flag & 0x01);
|
||
const payloadLen = is2Byte
|
||
? this.readUint16LE(offset + 6)
|
||
: this.readUint32LE(offset + 6);
|
||
|
||
if (__DEV__) console.log(
|
||
`[Parser] header @ ${offset}: flag=0x${flag.toString(16)} func=0x${func.toString(16)} lenMode=${is2Byte ? '2B' : '4B'} payloadLen=${payloadLen} bufLen=${this.len}`,
|
||
'hdr:', Array.from(this.buf.slice(offset, offset + 10)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '),
|
||
);
|
||
|
||
if (payloadLen > MAX_PAYLOAD_SIZE) {
|
||
if (__DEV__) console.warn(`[Parser] bogus payloadLen ${payloadLen} > MAX ${MAX_PAYLOAD_SIZE}, skipping magic`);
|
||
offset += 4;
|
||
continue;
|
||
}
|
||
|
||
const totalLen = 10 + payloadLen;
|
||
if (this.len - offset < totalLen) {
|
||
if (__DEV__) console.log(`[Parser] incomplete frame: have ${this.len - offset}, need ${totalLen}`);
|
||
break;
|
||
}
|
||
|
||
const payload = new Uint8Array(totalLen - 10);
|
||
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
|
||
if (__DEV__) console.log(`[Parser] ✓ frame complete: func=0x${func.toString(16)} payload=${payloadLen}B`);
|
||
results.push({ flag, func, payload });
|
||
offset += totalLen;
|
||
}
|
||
|
||
// 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 readUint16LE(offset: number): number {
|
||
return this.buf[offset] | (this.buf[offset + 1] << 8);
|
||
}
|
||
|
||
private readUint32LE(offset: number): number {
|
||
const b = this.buf;
|
||
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
|
||
}
|
||
}
|
||
|
||
// 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
|
||
const ADC_FULL_SCALE_V = 5.0;
|
||
const INT32_MAX = 0x7fffffff;
|
||
// raw → μV: (raw / 0x7FFFFFFF) * 5V / gain * 1e6
|
||
const ADC_UV_SCALE = (ADC_FULL_SCALE_V * 1e6) / INT32_MAX;
|
||
|
||
export function parseAdcData(
|
||
payload: Uint8Array,
|
||
channelNum: number,
|
||
_accNum: number,
|
||
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
|
||
const META_SIZE = 54;
|
||
const sampleDataLen = payload.length - META_SIZE;
|
||
const totalSamples = sampleDataLen / 4;
|
||
const samplesPerChannel = Math.floor(totalSamples / channelNum);
|
||
|
||
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
|
||
const gain = AMP_GAIN[payload[30]] ?? 1;
|
||
const scale = ADC_UV_SCALE / gain;
|
||
|
||
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 = (i * channelNum + ch) * 4;
|
||
const val = view.getInt32(idx, true);
|
||
raw[i] = val;
|
||
uv[i] = val * scale;
|
||
}
|
||
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 = '';
|
||
const reasonOffset = 22;
|
||
if (payload.length > reasonOffset) {
|
||
for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) {
|
||
if (payload[i] === 0) break;
|
||
reason += String.fromCharCode(payload[i]);
|
||
}
|
||
}
|
||
return { result, reason };
|
||
}
|