cyton-dongle

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

16 stars

Best use case

cyton-dongle is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

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

Teams using cyton-dongle should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/cyton-dongle/SKILL.md --create-dirs "https://raw.githubusercontent.com/plurigrid/asi/main/.claude/skills/cyton-dongle/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/cyton-dongle/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How cyton-dongle Compares

Feature / Agentcyton-dongleStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

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

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# 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:

```python
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

```python
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

```python
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`