Asi cyton-dongle
Connect and stream from OpenBCI Cyton/Daisy via USB dongle, including first-time radio channel pairing
git clone https://github.com/plurigrid/asi
T=$(mktemp -d) && git clone --depth=1 https://github.com/plurigrid/asi "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/cyton-dongle" ~/.claude/skills/plurigrid-asi-cyton-dongle && rm -rf "$T"
.claude/skills/cyton-dongle/SKILL.mdCyton Dongle
USB wireless receiver (RFD22301/RFDuino) for OpenBCI Cyton 8/16-channel EEG board.
Hardware
- Dongle: FTDI FT231X USB-UART → RFDuino 2.4 GHz radio
- Serial: 115200 baud, 8N1
- Device:
(macOS) or/dev/cu.usbserial-*
(Linux)/dev/ttyUSB* - Sample Rate: 250 Hz
- Channels: 8 (Cyton) or 16 (Cyton + Daisy)
- Packet: 33 bytes (0xA0 start, 24 bytes channel data, 6 bytes aux, 1 byte counter, 0xC0 stop)
First-Time Pairing (Critical)
A new dongle and board are typically on different radio channels. The standard
0xF0 0x01 channel-set command requires both sides to handshake — it fails when they're on different channels.
Use
0xF0 0x02 (CHANNEL_SET_OVERRIDE) to force the dongle to each channel without requiring board response, then check system status:
import serial, time ser = serial.Serial('/dev/cu.usbserial-XXXXX', 115200, timeout=2) time.sleep(2) for chan in range(26): ser.reset_input_buffer() ser.write(bytes([0xF0, 0x02, chan])) # override dongle (no handshake) time.sleep(0.5) ser.read(ser.in_waiting or 512) ser.reset_input_buffer() ser.write(bytes([0xF0, 0x07])) # system status query time.sleep(0.5) resp = ser.read(ser.in_waiting or 512).decode('utf-8', errors='ignore') if 'System is Up' in resp: print(f'FOUND BOARD ON CHANNEL {chan}') break else: print(f'Ch {chan}: Down') ser.close()
Radio Commands (0xF0 prefix)
| Bytes | Command | Notes |
|---|---|---|
| CHANNEL_GET | Returns current dongle channel |
| CHANNEL_SET | Coordinated change, requires board online |
| CHANNEL_OVERRIDE | Dongle-only, no handshake — use for pairing |
| POLL_TIME_GET | Current poll time |
| POLL_TIME_SET | Set poll time |
| BAUD_DEFAULT | 115200 |
| BAUD_FAST | 230400 |
| SYS_STATUS | "System is Up" or "System is Down" |
| BAUD_HYPER | 921600 |
Channels are 0-25. Default for new boards is usually 1.
Serial Commands
| Cmd | Action |
|---|---|
| Firmware version + board info |
| Start binary streaming |
| Stop streaming |
| Enable Daisy (16ch mode) |
| Query Daisy module |
| Print ADS1299 registers |
- | Default channel settings (ch 1-8) |
- | Default channel settings (ch 9-16, Daisy) |
| Reset all channel defaults |
Parsing Binary Packets
SCALE_UV = 4.5 / (24 * (2**23 - 1)) * 1e6 # ~0.02235 uV/count def parse_24bit(b0, b1, b2): val = (b0 << 16) | (b1 << 8) | b2 return val - 0x1000000 if val >= 0x800000 else val
33-byte packet:
0xA0 | sample_num | 8×3-byte channels | 6-byte aux | 0xC0
With Daisy: odd sample numbers = channels 1-8, even = channels 9-16.
Streaming and Channel Quality Check
import serial, time, math ser = serial.Serial('/dev/cu.usbserial-XXXXX', 115200, timeout=5) time.sleep(2) ser.reset_input_buffer() # Override to known channel ser.write(bytes([0xF0, 0x02, CHANNEL])) time.sleep(1) ser.read(ser.in_waiting or 512) # Reset board ser.write(b'v') time.sleep(3) ser.read(ser.in_waiting or 4096) ser.reset_input_buffer() SCALE_UV = 4.5 / (24 * (2**23 - 1)) * 1e6 def p24(b0, b1, b2): v = (b0 << 16) | (b1 << 8) | b2 return v - 0x1000000 if v >= 0x800000 else v # Start stream ser.write(b'b') time.sleep(1.5) ser.read(ser.in_waiting or 2048) # drain text samples = {i: [] for i in range(16)} t0 = time.time() while (time.time() - t0) < 4: avail = ser.in_waiting if not avail: time.sleep(0.01) continue buf = ser.read(avail) i = 0 while i < len(buf) - 32: if buf[i] == 0xA0 and buf[i+32] == 0xC0: sn = buf[i+1] is_daisy = (sn % 2 == 0) for ch in range(8): off = i + 2 + ch * 3 raw = p24(buf[off], buf[off+1], buf[off+2]) samples[ch + (8 if is_daisy else 0)].append(raw * SCALE_UV) i += 33 else: i += 1 ser.write(b's') ser.close() # Assess quality for ch in range(16): vals = samples[ch] if len(vals) < 10: print(f'Ch {ch+1}: NO DATA') continue mean = sum(vals) / len(vals) std = math.sqrt(sum((v - mean)**2 for v in vals) / len(vals)) if abs(mean) > 187000: q = 'RAILED' elif std < 1: q = 'FLAT' elif std > 200: q = 'BAD CONTACT' elif std > 100: q = 'NOISY' elif std < 50: q = 'CLEAN' else: q = 'OK' print(f'Ch {ch+1}: {q} (std={std:.1f} uV)')
Ultracortex Mark IV 16ch Montage (10-20)
| Ch | Position | Ch | Position |
|---|---|---|---|
| 1 | Fp1 | 9 | F7 |
| 2 | Fp2 | 10 | F8 |
| 3 | C3 | 11 | F3 |
| 4 | C4 | 12 | F4 |
| 5 | P7 | 13 | T7 |
| 6 | P8 | 14 | T8 |
| 7 | O1 | 15 | P3 |
| 8 | O2 | 16 | P4 |
Daisy Module (16ch)
The Daisy stacks on top of the Cyton, adding a second ADS1299 for channels 9-16.
Verifying Daisy:
should report:vOn Daisy ADS1299 Device ID: 0x3E
returns Daisy firmware version (e.g.,D
)060110
enables 16ch mode, returnsC16
(lowercase) disables Daisy, returnscdaisy removed
Daisy interleaving: In 16ch mode, the board alternates packets:
- Odd sample numbers (1,3,5...): channels 1-8 (main board)
- Even sample numbers (2,4,6...): channels 9-16 (Daisy)
Expect ~1:1 ratio of main:daisy packets. If Daisy packets are missing or all-zero, check that the Daisy board is firmly seated on the Cyton header pins.
ADS1299 Registers
Query with
?. Key registers per channel:
| Register | Default | Meaning |
|---|---|---|
| Normal input, gain 24x, powered on | |
| Powered down (bit 7 set) | |
| Normal input, gain 24x, SRB2 off |
: All channels feeding bias drive (good)BIAS_SENSP = 0xFF
: 250 Hz sample rate, daisy modeCONFIG1 = 0xB6
: Internal reference, bias enabledCONFIG3 = 0xEC
Electrode Quality Thresholds
| Std Dev (uV) | Status | Meaning |
|---|---|---|
| < 1 | FLAT | Shorted to reference or no contact |
| < 50 | CLEAN | Good signal, usable for all analysis |
| 50-100 | OK | Usable for most band power analysis |
| 100-200 | NOISY | May work for gross features (eye blinks) |
| > 200 | BAD CONTACT | Electrode touching but loose |
| mean ±187500 | RAILED | Not touching skin, pinned to ADC rail |
Session Persistence
The dongle does not persist the channel override across serial sessions. Every time you open a new serial connection, you must re-send
0xF0 0x02 <channel>. Keep the serial port open for the duration of your recording, or store the known channel and re-override on connect.
The board also goes to sleep after extended idle with no streaming. Toggle the power switch OFF→PC to wake it, then re-scan.
Dongle Switch Position
The dongle has a small switch with two positions:
| Position | Mode | Use |
|---|---|---|
| GPIO_6 | Normal operation | Use this for data streaming |
| Reset | Bootloader/programming | Firmware upload only |
If the switch is on "Reset", commands may partially work (radio config,
v, ?) but binary streaming will fail — the RFDuino stays in bootloader mode and cannot relay continuous data. This is easy to miss because single-shot commands still get responses.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Device failed to poll Host" | Channel mismatch | Use override scan (see above) |
| "System is Down" | Board off or wrong channel | Check power, scan channels |
| Channel stuck on set | needs board handshake | Use override instead |
| RAILED at ±187500 uV | Electrode not connected | Check pin seating and wire |
| FLAT near 0 | Shorted to ref or no contact | Apply gel, press electrode |
| FLAT at exactly 0.0 | Daisy wires not plugged in | Check header pin connections |
| High noise (>200 uV std) | Poor electrode contact | Tighten cap, add paste |
0 packets after | Dongle switch on "Reset" | Set switch to GPIO_6 position |
| Commands work, stream doesn't | Dongle in bootloader mode | Check switch is GPIO_6, not Reset |
| Daisy ch all zero | Daisy not seated or not sent | Reseat Daisy, send before |
| All channels railed one side | Cap too loose / wrong size | Tighten straps, try gel electrodes |
| Commands work but stream doesn't | Board slept during idle | Toggle OFF→PC, re-pair |
Firmware Source
- Dongle:
github.com/OpenBCI/OpenBCI_Radios - Board:
github.com/OpenBCI/OpenBCI_32bit_Library