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#
Client: Construct a
BrainAccessClientwith aTransport. On Android useNordicTransport(context). The client owns the scanner and every live session, so scanning while connected and multiple simultaneous devices work out of the box.Scanning:
scan()returns aFlow<DiscoveredDevice>that emits as devices are discovered. Cancel the collector to stop scanning;.first()takes the first result.Connecting:
connect(device)suspends until the device reachesDeviceState.Ready— it connects, discovers services, raises the MTU, subscribes to battery and stream notifications, and applies the stored (or default) config. It returns aResult<DeviceSession>; a failed result carries a typedBrainAccessErrorand any partial connection is torn down for you.Streaming:
session.stream()is aFlow<EegChunk>. Collecting it moves the device toDeviceState.Streaming; completing the collection stops the stream. EachEegChunkis one decoded packet — one object per packet, not per sample.Stopping: Cancel the coroutine collecting
stream().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"