Usage#

This guide walks through connecting to a BrainAccess device, streaming EEG data, and disconnecting with the BrainAccessKit SDK.

The SDK is coroutine- and Flow-based: scanning, connecting and streaming are all suspend functions or Flows, so drive them from a coroutine scope (e.g. an Activity’s lifecycleScope).

Connecting and streaming#

 1// 1. Create a client, backed by the Android (Nordic) BLE transport.
 2val client = BrainAccessClient(NordicTransport(context))
 3
 4// 2. Scan, connect, and stream from a coroutine.
 5val streaming = lifecycleScope.launch {
 6    // scan() emits DiscoveredDevices as they are found; take the first.
 7    val device = client.scan().first()
 8
 9    // connect() suspends until the device is Ready; returns Result<DeviceSession>.
10    val session = client.connect(device).getOrThrow()
11
12    // stream() is a Flow<EegChunk>; collecting it starts the stream.
13    session.stream().collect { chunk ->
14        // chunk.samples: flat FloatArray of µV, row-major, laid out as
15        // [sampleCount * chunk.channels.size]. Labels + srate travel with the data.
16        println("${chunk.sampleCount} samples on ${chunk.channels}")
17    }
18}
19
20// 3. Stop streaming by cancelling the collecting coroutine.
21streaming.cancel()
22
23// 4. Disconnect every session when you are done with the client.
24lifecycleScope.launch { client.disconnectAll() }

Explanation#

  1. Client: Construct a BrainAccessClient with a Transport. On Android use NordicTransport(context). The client owns the scanner and every live session, so scanning while connected and multiple simultaneous devices work out of the box.

  2. Scanning: scan() returns a Flow<DiscoveredDevice> that emits as devices are discovered. Cancel the collector to stop scanning; .first() takes the first result.

  3. Connecting: connect(device) suspends until the device reaches DeviceState.Ready — it connects, discovers services, raises the MTU, subscribes to battery and stream notifications, and applies the stored (or default) config. It returns a Result<DeviceSession>; a failed result carries a typed BrainAccessError and any partial connection is torn down for you.

  4. Streaming: session.stream() is a Flow<EegChunk>. Collecting it moves the device to DeviceState.Streaming; completing the collection stops the stream. Each EegChunk is one decoded packet — one object per packet, not per sample.

  5. Stopping: Cancel the coroutine collecting stream().

  6. Disconnecting: client.disconnect(device.id) tears down one session; client.disconnectAll() tears down all of them.

Observing device state#

A DeviceSession exposes reactive StateFlows you can collect alongside the stream:

  • state: StateFlow<DeviceState> — Connecting / Discovering / Ready / Streaming / Updating / Error / Disconnected.

  • battery: StateFlow<Float> — battery percentage.

  • contact: StateFlow<BooleanArray> — per-active-channel electrode contact (false = not connected).

  • missedSamples: StateFlow<Long> — cumulative device-side samples lost over the air (gap-filled) this session.

  • info: StateFlow<DeviceInfo?> — Device Information Service strings.

  • hardware: DeviceHardware — the board generation, read from the device’s Model Number (HALO_V2, HALO_V3, MINI_V2, MIDI, MAXI, EXG8). It says which sensors the headset has: hasAccel / hasGyro.

The phone’s Bluetooth adapter state is on the client: client.bluetoothState: StateFlow<Boolean>.

Motion data#

On hardware with an IMU, every EegChunk carries accel (flat [sampleCount * 3], x/y/z in m/s²) and, on V3 boards, gyro (x/y/z in degrees per second) — the same units the BrainAccess C/Python SDK delivers. Both are null when the hardware lacks the sensor (session.hardware.hasAccel / hasGyro). A HALO V1/V2 has neither; a HALO V3 has both.

Test signal#

Firmware 3.7.0 and later can replace the electrode data with the ADS1299’s internal test signal: a square wave of 3.74 mV peak-to-peak at about 1 Hz on every channel, the same in µV at every gain — a quick end-to-end check of the signal chain. session.supportsTestSignal says whether the headset has it; older firmware does not, and streams electrode data only.

if (session.supportsTestSignal) {
    session.stream(OutputMode.TEST_SIGNAL).collect { chunk -> /* ±1.87 mV square wave */ }
}

The headset keeps the selected mode until it is powered off, whoever selected it, so stream() writes OutputMode.NORMAL on every start of a headset that has the setting. The test signal replaces the impedance measurement; request it with impedance off.

Configuring the device#

Every model has a factory-default montage (all channels active, gain 8, 250 Hz, impedance off). To change it, build a DeviceConfig and apply it — the config is persisted per device and re-applied automatically on reconnect:

val cfg = session.model.defaultConfig().copy(srate = 500)
session.configure(cfg).getOrThrow()

DeviceConfig accepts only the sample rates the headset protocol can express (DeviceConfig.SUPPORTED_SRATES: 250, 500, 1000 Hz); which of them a model delivers is a hardware property (HALO and MINI 250 or 500 Hz, MIDI and MAXI 250 Hz).

Errors#

Fallible operations return Result carrying a BrainAccessError rather than crashing or failing silently: MissingPermission, BluetoothOff, LocationOff, Timeout, GattError(status), Disconnected, NotConnected, ConfigRejected (firmware 3.7.0+ refused the channel configuration), TestSignalNotSupported, plus OTA-specific errors.

For a complete, working example, see the Examples page.

Versions used for testing:#

agp = "8.10.1"
gradle = "8.11.1"
kotlin = "2.0.21"
nordic-ble = "2.9.0"