Agent Skills: Cyton Dongle

Connect and stream from OpenBCI Cyton/Daisy via USB dongle, including first-time radio channel pairing

UncategorizedID: plurigrid/asi/cyton-dongle

Install this agent skill to your local

pnpm dlx add-skill https://github.com/plurigrid/asi/tree/HEAD/.claude/skills/cyton-dongle

Skill Files

Browse the full folder contents for cyton-dongle.

Download Skill

Loading file tree…

.claude/skills/cyton-dongle/SKILL.md

Skill Metadata

Name
cyton-dongle
Description
Connect and stream from OpenBCI Cyton/Daisy via USB dongle, including first-time radio channel pairing

Cyton 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: /dev/cu.usbserial-* (macOS) or /dev/ttyUSB* (Linux)
  • 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 | |-------|---------|-------| | 0xF0 0x00 | CHANNEL_GET | Returns current dongle channel | | 0xF0 0x01 <ch> | CHANNEL_SET | Coordinated change, requires board online | | 0xF0 0x02 <ch> | CHANNEL_OVERRIDE | Dongle-only, no handshake — use for pairing | | 0xF0 0x03 | POLL_TIME_GET | Current poll time | | 0xF0 0x04 <t> | POLL_TIME_SET | Set poll time | | 0xF0 0x05 | BAUD_DEFAULT | 115200 | | 0xF0 0x06 | BAUD_FAST | 230400 | | 0xF0 0x07 | SYS_STATUS | "System is Up" or "System is Down" | | 0xF0 0x0A | BAUD_HYPER | 921600 |

Channels are 0-25. Default for new boards is usually 1.

Serial Commands

| Cmd | Action | |-----|--------| | v | Firmware version + board info | | b | Start binary streaming | | s | Stop streaming | | C | Enable Daisy (16ch mode) | | D | Query Daisy module | | ? | Print ADS1299 registers | | 1-8 | Default channel settings (ch 1-8) | | !-* | Default channel settings (ch 9-16, Daisy) | | d | 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:

  • v should report: On Daisy ADS1299 Device ID: 0x3E
  • D returns Daisy firmware version (e.g., 060110)
  • C enables 16ch mode, returns 16
  • c (lowercase) disables Daisy, returns daisy 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 | |----------|---------|---------| | 0x68 | Normal input, gain 24x, powered on | | 0xE8 | Powered down (bit 7 set) | | 0x60 | Normal input, gain 24x, SRB2 off |

  • BIAS_SENSP = 0xFF: All channels feeding bias drive (good)
  • CONFIG1 = 0xB6: 250 Hz sample rate, daisy mode
  • CONFIG3 = 0xEC: Internal reference, bias enabled

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 0xF0 0x02 override scan (see above) | | "System is Down" | Board off or wrong channel | Check power, scan channels | | Channel stuck on set | 0x01 needs board handshake | Use 0x02 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 b | 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 C not sent | Reseat Daisy, send C before b | | 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