Skip to content

Creating a Custom Data Parser

The Knight Board sends one fixed-length binary frame per sample over its USB serial connection. A custom parser only needs to synchronize on the frame markers, decode the common EEG block, and use the correct frame length for the firmware mode.

SettingValue
Baud rate115200
Data format8 data bits, no parity, 1 stop bit
Sample rate125 frames per second
Start byte0xA0
End byte0xC0

Firmware may print human-readable status lines before streaming starts. A parser should discard input until it finds a complete frame beginning with 0xA0 and ending with 0xC0. Custom firmware can suppress the boot messages by calling neuropawn.quiet(true) before neuropawn.setup().

The first 20 bytes are identical in both modes:

OffsetSizeField
01Start of frame (0xA0)
11Frame counter, rolling from 0 through 255
216Eight signed 16-bit EEG samples, big-endian, channels 1 to 8
181Positive-input electrode status, bit 0 = channel 1
191Negative-input electrode status, bit 0 = channel 1

EEG samples are signed two’s-complement values. In Python, decode all eight at once with struct.unpack_from(">8h", frame, 2).

An EEG-only frame is 21 bytes:

OffsetSizeField
0-1920Common EEG block
201End of frame (0xC0)

No IMU placeholder bytes are present. The end marker immediately follows the two electrode-status bytes.

An IMU frame is 57 bytes. It contains the same 20-byte EEG block followed by nine little-endian IEEE-754 float32 values:

OffsetSizeField
0-1920Common EEG block
20-3112Accelerometer X, Y, Z in m/s2
32-4312Gyroscope X, Y, Z in rad/s
44-5512Magnetometer X, Y, Z in microtesla
561End of frame (0xC0)

Use struct.unpack_from("<9f", frame, 20) to decode the complete IMU payload. If the firmware is in NP_IMU mode but the sensor is absent, all nine values are zero and the frame remains 57 bytes long.

Install pySerial with pip install pyserial, then select the mode that matches the firmware:

knight_parser.py
import struct
from dataclasses import dataclass
import serial
START_BYTE = 0xA0
END_BYTE = 0xC0
FRAME_LENGTHS = {"default": 21, "imu": 57}
@dataclass
class KnightSample:
counter: int
eeg_counts: tuple[int, ...]
positive_status: int
negative_status: int
acceleration: tuple[float, float, float] | None = None
gyroscope: tuple[float, float, float] | None = None
magnetometer: tuple[float, float, float] | None = None
class KnightParser:
def __init__(self, mode: str = "default"):
if mode not in FRAME_LENGTHS:
raise ValueError("mode must be 'default' or 'imu'")
self.mode = mode
self.frame_length = FRAME_LENGTHS[mode]
self.buffer = bytearray()
def feed(self, data: bytes):
"""Yield every complete sample found in an arbitrary serial chunk."""
self.buffer.extend(data)
while True:
start = self.buffer.find(bytes([START_BYTE]))
if start < 0:
self.buffer.clear()
return
if start:
del self.buffer[:start]
if len(self.buffer) < self.frame_length:
return
if self.buffer[self.frame_length - 1] != END_BYTE:
del self.buffer[0]
continue
frame = bytes(self.buffer[: self.frame_length])
del self.buffer[: self.frame_length]
yield self.decode(frame)
def decode(self, frame: bytes) -> KnightSample:
eeg = struct.unpack_from(">8h", frame, 2)
sample = KnightSample(
counter=frame[1],
eeg_counts=eeg,
positive_status=frame[18],
negative_status=frame[19],
)
if self.mode == "imu":
imu = struct.unpack_from("<9f", frame, 20)
sample.acceleration = imu[0:3]
sample.gyroscope = imu[3:6]
sample.magnetometer = imu[6:9]
return sample
port = serial.Serial("COM3", 115200, timeout=1)
parser = KnightParser(mode="imu")
while True:
for sample in parser.feed(port.read(port.in_waiting or 1)):
print(sample.counter, sample.eeg_counts, sample.acceleration)

The parser accepts arbitrary chunks rather than assuming that one serial read equals one frame. On a bad end marker it discards the candidate start byte and searches again, allowing it to recover from boot text, dropped bytes, or opening the port in the middle of a stream.

For consecutive frames, the expected counter is:

expected = (previous_counter + 1) & 0xFF
if sample.counter != expected:
dropped = (sample.counter - expected) & 0xFF
print(f"Dropped {dropped} frame(s)")

Do not use the frame counter as a timestamp; it wraps every 256 samples. Record host arrival times separately if your application needs timing metadata.

The EEG scale depends on the configured gain:

extmicrovoltspercount=42151×106gain ext{microvolts per count} = \frac{4}{2^{15}-1} \times \frac{10^6}{\text{gain}}
gain = 12
scale = 4 / (2**15 - 1) / gain * 1_000_000
eeg_microvolts = tuple(value * scale for value in sample.eeg_counts)

Keep the parser’s gain synchronized with the chon_<channel>_<gain> commands sent to the board. For applications that do not need a custom transport, BrainFlow already handles framing, conversion, and its ring buffer.