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(); the SDK stops the headset’s stream. A newstream()can follow on the same connection.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.sampleCounterJumpscounts counter changes gap-fill does not represent (a counter that moved back or jumped more than 8 s),undecodablePacketspackets that could not be decoded, andstalePacketspackets skipped because every sample in them was already delivered.charging/chargerConnected/batteryLow: StateFlow<Boolean?>— charge state, a plugged-in charger (still true once a full cell stops charging) and the device’s own low-battery flag;nullwhen unknown.info: StateFlow<DeviceInfo?>— Device Information Service strings.name: String— the advertised BLE name (BA MAXI 1234); the headset’s identity on LSL and in recordings.hardware: DeviceHardware— the board generation, read from the device’s Model Number and hardware revision (HALO_V2,HALO_V3,MINI_V2,MIDI,MAXI,EXG8). It says which sensors the headset has (hasAccel/hasGyro) and which sample rates it streams (supportedSrates).link: StateFlow<LinkInfo>— the negotiated BLE link: MTU, PHY, connection interval (1.25 ms units), latency and supervision timeout (10 ms units); every field butmtuisnulluntil the phone’s Bluetooth stack reports it.
session.readBatteryGauge() reads the full battery gauge on demand — voltage (mV), current
(mA, negative while discharging), health and charge level — or returns null on a headset
without it.
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.
Sample numbers and timing#
Each EegChunk is numbered in the headset’s own sample counter:
firstSampleIndex— the device sample number of the chunk’s first row; in normal mode rowsisfirstSampleIndex + s(impedance mode: see Impedance). Numbers only increase within a stream and are exact for any recording length. They follow the headset’s counter, which current firmware restarts at 0 on every stream start.isDummy/streaming(s)— a row the SDK filled in for a sample lost over the air (all-zero data,streaming0). Gaps longer than 8 s are not filled; they count insampleCounterJumps.hostTimeNs— the phone’s monotonic clock when the packet left the Bluetooth stack.wireLastSampleIndexandpacketBytes— the raw counter in the packet and its length, unmodified, for diagnosing the link.wireLastSampleIndexis-1on a packet too short to carry it.
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 */ }
}
stream() writes OutputMode.NORMAL on every start of a headset that has the setting, so a
stream is in normal mode unless you ask for the test signal. The test signal replaces the
impedance measurement; request it with impedance off.
Digital input#
Firmware 3.8.1 and later can send the level of the headset’s digital input (the first ADS1299’s
GPIO1) with every sample. session.supportsDigitalInput says whether the headset has it;
stream(digitalInput = true) on a headset without it fails with
BrainAccessError.DigitalInputNotSupported before anything is written.
session.stream(digitalInput = true).collect { chunk ->
val levels = chunk.digitalInput!! // one Boolean per sample; gap-filled samples are false
}
chunk.digitalInput is null for a stream that did not ask for it. Over LSL,
streamToLsl(scope, digitalInput = true) adds the Digital channel (type TRIG) of the
BrainAccess Board LSL contract. On a headset without a digital input connector the level reads
high for the whole stream.
Markers and time sync#
To mark an event on the sample the headset was converting when it happened, ask the session at the moment of the event rather than using the newest received sample, which is late by the packet’s trip over the radio and any buffering:
val sample: Long? = session.sampleIndexAt(System.nanoTime()) // null when not streaming
The number is in the EegChunk.firstSampleIndex space (while sampleCounterJumps is 0). On
firmware 3.8.0 and later the session pings the headset’s time-sync characteristic while it
streams and maps phone time to the device’s sample counter; on older firmware, in impedance mode
and in the first half second of a stream it estimates the sample from packet timing.
session.timeSyncInfo() reports which applies and how well the clocks fit.
With a valid clock fit, session.sampleNearest(hostTimeNs) gives the same sample without the
packet-timing fallback (null otherwise), and session.sampleHostTimeNs(sample) maps the other
way, from a device sample to its System.nanoTime() time.
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, 2000 Hz) and the gains the ADS1299 supports
(DeviceConfig.VALID_GAINS: 1, 2, 4, 6, 8, 12, 24). Which rates a headset streams is a property
of its board, session.hardware.supportedSrates: 250 and 500 Hz on every board, plus 1 kHz on
HALO V3 and 1 and 2 kHz on EXG8. configure() refuses any other rate before writing it.
An inactive channel may also carry gain 0. A config with no active channel is refused by
configure() and stream() (IllegalArgumentException) before anything is written.
Impedance#
Set impedanceMode to measure electrode impedance instead of EEG; every value in the stream
is then the headset’s measurement in kΩ:
session.configure(cfg.copy(impedanceMode = ImpedanceMode.HZ_31_2)).getOrThrow()
session.stream().collect { chunk -> /* chunk.samples in kOhm per active channel */ }
The headset computes each value over 25 conversions of every active channel, so the values of a
chunk are 25 × active channels sample numbers apart: row s was measured at
firstSampleIndex + s × 25 × active channels. Nothing is gap-filled between them, and
contact reads all-true while measuring.
The headset measures over a fixed 20-sample window, which catches the test wave only when the
wave is slow enough for the rate: HZ_7_8 up to 250 Hz, HZ_31_2 up to 1000 Hz, DR_DIV4
at every rate. Above that limit the SDK streams the measurement at 250 Hz so the values stay
valid. DeviceConfig.deviceSrate (and EegChunk.srate) is the rate actually streamed;
srate keeps the rate you set, so turning impedance off restores it.
Charging settings#
By default a headset switches its EEG off, drops the connection and stops advertising as soon as a charger is plugged in. To keep streaming while it charges, set the flag before plugging in (the headset reacts within a fraction of a second):
val current = session.readChargingSettings() ?: return // null: no charging-settings service
session.writeChargingSettings(current.copy(stayOnWhileCharging = true)).getOrThrow()
idlePowerOffMinutes (1–255, default 20) is how long a switched-on headset waits with no host
connected before it powers off. Both values live in the headset’s memory until it is powered off.
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), StreamStartFailed (firmware 3.7.0+ could not start its
EEG converter; a later start may succeed), TestSignalNotSupported, DigitalInputNotSupported
(firmware before 3.8.1), plus OTA-specific errors.
After a successful firmware update the headset reboots switched off: it advertises again only
after the user long-presses its power button. After reconnecting, session.info reports the
firmware version the headset runs.
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"