Skip to main content

5. Serial Protocol

Goal: Understand the EEGRST/EEGCFG/SW,START initialization sequence and the binary packet parser internals.

Files Involved​

FileRole
src/serial/webSerialAdapter.tsnavigator.serial wrapper: open, close, read, write
src/serial/serialInitialization.tsEEGRST + EEGCFG command sequencing
src/serial/serialAcquisitionSwitch.tsSW,START / SW,STOP commands
src/serial/serialHardwareConfig.tsHardware parameter encoding
src/serial/serialProtocolCore.tsSequence number math (wrap-around, gap detection)
src/serial/serialEegProtocol.tsBinary frame parser (int24 decoding)
src/serial/serialConnectionSession.tsManages the reader loop and stream lifecycle
src/config/serial.tsBaud rate, ACK timeouts, stall timeout

Connection Sequence​

Browser Device
│ │
├─ requestPort() ───────────────▶│ Navigator shows device chooser
│ │
├─ port.open({ baudRate: 921600 })─▶
│ │
├─ EEGRST ──────────────────────▶│ Reset firmware state
│◀──────── ACK ─────────────────┤ (2s timeout)
│ │
├─ EEGCFG(params) ──────────────▶│ Configure hardware
│◀──────── ACK ─────────────────┤ (2s timeout)
│ │
├─ SW,START ────────────────────▶│ Begin sampling
│◀──────── ACK ─────────────────┤ (2s timeout)
│ │
│◀──── binary packets ──────────┤ 250 Hz int24 samples
│◀──── binary packets ──────────┤
│◀──── binary packets ──────────┤

Timeouts​

// src/config/serial.ts
export const EEG_SERIAL_RESET_ACK_TIMEOUT_MS = 2_000;
export const EEG_SERIAL_CONFIG_ACK_TIMEOUT_MS = 2_000;
export const EEG_SERIAL_SWITCH_ACK_TIMEOUT_MS = 2_000;
export const EEG_SERIAL_STALLED_TIMEOUT_MS = 2_000;

If any ACK times out, the connection is aborted and a diagnostic error is logged.

Sequence Number Handling​

File: src/serial/serialProtocolCore.ts

Each binary packet carries a sequence number. The parser uses it to:

Gap Detection​

export function countForwardSerialDeviceSeqGap(
expectedSeq: number,
actualSeq: number,
): number {
const gap = (actualSeq - expectedSeq) >>> 0; // unsigned 32-bit
if (gap === 0 || gap > 0x7fff_ffff) return 0; // ignore wrap-around
return gap;
}

A non-zero gap means packets were dropped. This is logged in diagnostics:

"Serial packet sequence jumped; N packets were missed."

Wrap-around​

Sequence numbers are uint32 and wrap at 0xFFFFFFFF:

export function getNextSerialDeviceSeq(seq: number): number {
return (seq + 1) >>> 0;
}

Binary Frame Parser​

File: src/serial/serialEegProtocol.ts

The parser reads from the serial stream looking for frame boundaries. Each frame contains:

[header/sync] [seq_num] [payload: int24 × N_channels] [checksum/footer]

Int24 Decoding​

EEG samples are encoded as 24-bit signed integers (3 bytes per sample). The parser converts these to JavaScript numbers:

// int24 → number (simplified)
function decodeInt24(bytes: Uint8Array, offset: number): number {
const b0 = bytes[offset];
const b1 = bytes[offset + 1];
const b2 = bytes[offset + 2];
let value = (b0 << 16) | (b1 << 8) | b2;
// Sign extension for negative values
if (value & 0x800000) value |= 0xFF000000;
return value;
}

Output​

After parsing a complete frame, the parser produces:

interface ParsedBatch {
samples: number[][]; // samples[channelIndex][sampleIndex]
packetSeq: number;
invalid?: boolean; // set if frame was malformed
}

Invalid packets are counted but not forwarded to the data pipeline.

How to Adapt for Different Hardware​

If your EEG device uses a different protocol:

  1. Create a new parser in src/serial/ — follow serialEegProtocol.ts as a template
  2. Wire it in src/hooks/useAcquisitionActions.ts — replace the parser instantiation
  3. Update serialHardwareConfig.ts if the config command format differs
  4. Adjust baud rate in src/config/serial.ts if needed
  5. Update diagnostic messages in src/i18n.ts for new error conditions

Parser Interface​

interface EegProtocolParser {
feedChunk(chunk: Uint8Array): ParsedBatch[];
reset(): void;
}

Your parser must implement feedChunk — the reader loop calls this with raw bytes from the serial port. Return an array of parsed batches (may be empty if no complete frame was found).

Reader Loop Lifecycle​

File: src/serial/serialConnectionSession.ts

The reader loop runs while streaming is active:

while (streamActive) {
const { value, done } = await reader.read();
if (done) break;

const batches = parser.feedChunk(value);
for (const batch of batches) {
if (batch.invalid) {
diagnostics.logInvalidPacket();
continue;
}
// Check sequence gaps
const gap = countForwardSerialDeviceSeqGap(lastSeq, batch.packetSeq);
if (gap > 0) diagnostics.logPacketGap(gap);

// Push to waveform buses
for (const [ch, samples] of batch.samples.entries()) {
for (const s of samples) {
rawWaveformBus.push(s, `ch${ch}`);
}
}

// Feed the filter → FFT pipeline
handleBatch(batch);
}
}

Stall Detection​

If no valid batch arrives for EEG_SERIAL_STALLED_TIMEOUT_MS (2 seconds), the stream state changes to STALLED. When data resumes, it recovers automatically.

Adding Diagnostics​

All diagnostic events go through src/store/eegStore.ts:

useEegStore.getState().pushDiagnostic({
id: crypto.randomUUID(),
phase: 'Serial connection', // or 'Data collection', 'Disconnect', 'Access revocation'
status: 'error', // 'running' | 'success' | 'error' | 'info'
message: 'Serial packet sequence jumped; 3 packets were missed.',
detail: `Expected seq ${expected}, got ${actual}`,
timestamp: Date.now(),
});

Use pushDiagnostic to log events from your parser or reader loop. They appear in the diagnostics drawer and System page.

Next​

→ Understand state management: Zustand vs. refs