This page is version 1.1 of the protocol contract, so it cannot disagree with the software it describes.

Each version adds to the one before it: 1.4, 1.3, 1.2, 1.1, 1.0. Version 1.1 restates the whole contract as it stood then.

IntoMind BLE Protocol v1.1

Status: in force from firmware 1.1.0, written 2026-09-25. This document is the contract between an IntoMind device and any host. Firmware, the api, the sdk, and the Command Center all implement it. Its canonical home moves to the api repository at publication. It supersedes 1.0 by addition only: a 1.0 host works against a 1.1 device, and a 1.1 host reads a 1.0 device's version and asks it nothing new.

What 1.1 adds: the processing chain the device runs on its signal and the operations to read and set it (section 16); where the model's input comes from; the bias drive, on devices that claim one; two capability bits; and two bytes that were reserved, in GET_MODEL_INFO and in the prediction header.

0. Versioning and compatibility

1. Design invariants

  1. On-device time is authoritative. Every sample carries a device time captured on the device, in hardware, at the converter's data-ready edge. The host maps device time onto its own clock. It never derives sample times from packet arrival.
  2. The timeline is never silently corrupted. A monotonic sample_index is the source of truth for continuity. Any loss is announced with an explicit discontinuity flag and count. The host reconstructs a timeline with marked holes, never a silent splice.
  3. The device processes its signal, and the stream declares what produced it. Filters are one class of the preprocessing a device hosts. When a chain is in force only its output streams, written back into the converter's own count domain so the scaling rule below is unchanged; when the chain is empty the natural signal streams. The chain is readable at any time, cannot change while streaming, and is what a recording stores beside its samples. There is no raw-versus-processed flag anywhere: a flag says nothing about what a signal is. The on-device model of section 10 consumes its own copy, taken from the stream unless a host points it elsewhere, and is additive: turning it on or off changes nothing about the stream.
  4. The link is private. Data, control, status, and updates require an encrypted, bonded connection. Neural data belongs to its owner.
  5. The contract adapts to hardware. Capabilities and capacities are read from the device, so one contract serves devices with different channel counts, battery sensing, model presence, or slot sizes.
  6. The model can never hurt the instrument. Every model surface is optional. A device with no valid model behaves exactly like a device with no model at all, and acquisition never waits on inference.

2. GATT structure

One primary service. 128-bit UUIDs from the base f3a1xxxx-2c4b-4d1e-9a6f-1b2c3d4e5f60, where xxxx is the fill below.

FillCharacteristicPropertiesPurpose
0001ServiceIntoMind Neural Stream
0002Device InfoReadidentity, capabilities, capacities (section 4)
0003Control PointWrite, Write Without Responsecommands (section 6)
0004Control ResponseIndicatecommand results
0005EEG DataNotifysample packets (section 5)
0006StatusRead, Notifylive device status (section 7)
0007reservedheld by an earlier development firmware, never reused
0008Update ControlWrite, Indicateupdate and head transfer commands (section 9)
0009Update DataWrite Without Responsetransfer payload bytes (section 9)
000APredictionsNotifymodel outputs (section 10)

All characteristics except Device Info require an encrypted, bonded link (section 12). Every characteristic value is at most 244 bytes, so nothing requires ATT fragmentation at an ATT MTU of 247. Notifications and indications are sized to the negotiated MTU, see section 3.

3. Advertising and connection

4. Device Info (read)

Little-endian. Read once per connection. Re-read after a weights update activates (section 9), since model fields can change then.

offset type    field
0      u8      proto_major            1
1      u8      proto_minor            0
2      u8      fw_major
3      u8      fw_minor
4      u8      fw_patch
5      u8      channel_count          4 on the IntoMind One
6      u8      adc_bits               24
7      u32     time_tick_hz           device time ticks per second, 1000000
11     u32     vref_uv                converter reference in microvolts, 4500000
15     u16     capabilities           bitmask, table below
17     u8      supported_rates        bit0 250, bit1 500, bit2 1000 SPS
18     u8      reserved
19     u8[8]   device_id              stable per-unit id from the SoC factory id
--- end of the v0.1 layout, 27 bytes ---
27     u8      info_len               total length of Device Info in bytes, 76 in 1.0
28     u8      hw_major               hardware revision, 1
29     u8      hw_minor               1
30     u8      hw_patch               5
31     u8      reserved
32     u8[8]   fw_build_id            identifies the exact firmware image
40     u16     model_embed_dim        embedding width, 96 on the IntoMind One, 0 = no model runtime
42     u16     model_native_sps       the model's native rate, 500
44     u16     model_window_samples   samples per prediction window at the native rate, 2000
46     u8      head_slots             user head slots, 4
47     u8      head_max_outputs       largest out_dim a head may have, 32
48     u16     head_slot_bytes        capacity of one head slot, 4096
50     u16     update_chunk_max       largest Update Data write accepted, 244
52     u32     app_slot_bytes         application image capacity including its header, 237568
56     u32     weights_image_bytes    weights image capacity including its header, 507904
60     u8[16]  reserved               zero
76     end

A host reads info_len and never reads past it. A later 1.x device may report a larger info_len with fields appended after offset 76.

Read Device Info again after any update activates, since the firmware version, the build id, and every model field can change across one.

Capabilities:

BitNameMeaning
0battery_voltageStatus.battery_percent and GET_BATTERY carry measured values
1battery_low_flagthe hardware has a low battery indicator
2leadofflead-off detection available
3test_signalinternal test signal mode available
4dcdc_modereserved for converter power mode selection
5input_shortinput short mode available
6updatethe Update service is present and accepts application and weights images
7modela model runtime is present in this firmware
8model_readyvalid weights were loaded at the time of this read, predictions can be enabled
9headsuser head slots are present, head operations are accepted
10pipelinethe device runs a processing chain and answers the operations of section 16. The IntoMind One sets this bit
11bias_drivethe device has a bias drive a host may set and read (section 16). The IntoMind One does not set this bit: its bias output reaches a pad and no further
12 to 15reservedzero

A device without a battery sense circuit reports bit 0 clear and battery_percent = 0xFF. The IntoMind One reports bit 0 set.

5. EEG Data (notify)

Header little-endian. The sample payload is the converter's own byte order: big-endian two's complement 24-bit integers, most significant byte first. The device copies converter bytes without per-sample swapping. The host sign-extends and scales.

offset type   field
0      u8     packet_type            0x01 = EEG data
1      u8     flags                  bit0 discontinuity_before_this_packet
                                     bit1 leadoff_active
                                     bits2-3 mode (0 normal, 1 test, 2 short)
                                     bit4 usb_present
2      u16    samples_lost_before    convenience count, saturates at 0xFFFF
4      u32    sample_index           index of the first sample, monotonic, wraps at 2^32
8      u64    device_time            device time of the first sample's data-ready edge, in time_tick_hz ticks
16     u8     n_samples
17     u8     loff_statp             lead-off status latched with the last sample in this packet, bit n = channel n+1
18     u8     gain_code              0..6 = gain 1, 2, 4, 6, 8, 12, 24
19     u8     rate_code              4 = 1000, 5 = 500, 6 = 250 SPS
20     ...    payload                n_samples x channel_count x 3 bytes

6. Control Point (write) and Control Response (indicate)

A request is [opcode, arg?], one or two bytes. Exactly the opcodes marked with an argument take one, and they require it. Any other length is invalid. Two opcodes new in 1.1, SET_PIPELINE and SET_PREDICTION_INPUT, carry a payload after the opcode byte whose layout section 16 defines; a request on one of them is as long as its payload. The device answers on Control Response with [opcode, status, payload?].

Status codes:

CodeMeaning
0OK
1invalid argument, or malformed request
2unsupported by this device or build
3busy: refused while streaming or while a transfer is active
4not streaming
5hardware error

Precedence when several apply: invalid argument, then unsupported, then busy, then hardware. A request is validated before device state is consulted.

Opcodes carried over from v0.1, unchanged:

OpcodeCommandArgNotes
0x01START_STREAMbegins continuous conversion and streaming
0x02STOP_STREAM
0x10SET_RATErate_coderefused with 3 while streaming
0x11SET_GAINgain_coderefused with 3 while streaming
0x20SET_MODE0, 1, 2normal, test signal, input short. Refused with 3 while streaming
0x30SET_LEADOFF0, 1refused with 3 while streaming
0x40TIME_SYNCpayload u64 device_time captured at request receipt
0x41SET_SAMPLES_PER_PACKET1..NN is the largest count that fits the MTU. Larger values are clamped to N and reported in the payload as u8 applied
0x50RESET_EPOCHzero sample_index, start a fresh epoch. The next packet carries the discontinuity flag with a zero loss count
0xF0SOFT_RESETre-initialize the device. The link drops

Opcodes new in 1.0:

OpcodeCommandArgPayloadNotes
0x42GET_BATTERYu16 battery_mv, u8 battery_percent, u8 charger_staterequires capability bit 0, else status 2. battery_percent follows Status
0x43GET_BOOT_INFOu8 active_slot, u8 boot_reason, u8 slot_state, u8 reserved, u32 boot_counttable below
0x53CLEAR_BONDSforget every bond except the requesting host's. Takes effect at the next disconnect
0x80SET_PREDICTIONS0, 1status 2 without a ready model or a selected head. Predictions flow only while streaming
0x81SELECT_HEADslot0 = the built-in head, 1..head_slots = user slots. Status 1 for an empty or unknown slot
0x82LIST_HEADSsee below
0x83REMOVE_HEADslot1..head_slots only. Refused with 3 while streaming
0x84GET_MODEL_INFOsee below

GET_BOOT_INFO fields: active_slot 0 = A, 1 = B. slot_state 0 = trial (the running image has not yet confirmed itself), 1 = confirmed. boot_count counts boots of this unit since manufacture. boot_reason:

ValueReason
0power on
1reset pin
2software reset
3watchdog
4CPU lockup or system fault
5update activation
6rollback, the bootloader reverted to the previous image
0xFFunknown

A watchdog or fault reset is also visible in the stream as a discontinuity.

LIST_HEADS payload: u8 active_slot, u8 n_entries, then n_entries records of 30 bytes:

u8     slot        0 = built-in
u8     state       0 empty, 1 valid, 2 invalid (failed its hash at load)
u16    out_dim
u8[8]  head_id     first 8 bytes of the head's SHA-256, zero when empty
u8[16] name        UTF-8, zero padded
u8[2]  reserved

n_entries is 1 + head_slots when a model runtime is present. Without one the request answers status 2.

GET_MODEL_INFO payload:

u8     model_state      0 no runtime, 1 runtime present but no valid weights, 2 ready
u8     active_head      slot, or 0xFF when none
u8     predictions_on   0, 1
u8     reserved
u8[8]  encoder_id       identity of the loaded weights, zero unless ready
u8     weights_major
u8     weights_minor
u8     weights_patch
u8     input_classes    1.1: the signal classes the loaded model takes, bit 0 time domain, bit 1 representation. A 1.0 device sends 0, which reads as the time domain

Opcodes new in 1.1. A 1.0 device answers status 1 to them, which a host never sees because it sends them only to a device whose Device Info sets the capability bit named for each.

OpcodeCommandArg or payloadPayloadNeedsNotes
0x90GET_PIPELINE_CATALOGcatalog, section 16pipelinethe stage kinds this device runs
0x91GET_PIPELINEu8 origin, chainpipelineorigin 0 = the device's default for the current rate, 1 = set by a host
0x92SET_PIPELINEchainpipelinerefused with 3 while streaming, with 1 when a stage cannot run at the current rate; the chain in force is then untouched
0x93CLEAR_PIPELINEpipelinethe natural signal, a host's choice. Refused with 3 while streaming
0x94RESTORE_PIPELINE_DEFAULTpipelinethe device's default for the current rate. Refused with 3 while streaming
0x85SET_PREDICTION_INPUTu8 source, chainmodel0 = the stream, 1 = the natural signal, 2 = a chain of the model's own, which only source 2 carries. Status 1 when the chain cannot run at the current rate or produces a class the model does not take; status 2 without a model runtime. Allowed while streaming
0x86GET_PREDICTION_INPUTu8 source, chainmodelthe chain in effect for the model: the stream's for source 0, empty for 1, its own for 2
0x32SET_BIAS0, 1, 2bias_driveoff, on, loop open. Refused with 3 while streaming
0x33GET_BIAS_DIAGNOSTICi16 mean_mv, i16 sd_mv, i16 min_mv, i16 max_mvbias_drivethe bias output over the device's own window

SET_RATE, unchanged in form, now also carries the chain: the device's own default is recomposed for the new rate, and a host's chain that cannot run at the new rate refuses the rate with status 1, so the host changes the chain first. Nothing is ever trimmed in silence.

Opcodes 0x60 to 0x6F are reserved for factory and bench builds. Production firmware answers status 2 to them.

7. Status (read and notify)

u8   state                 0 idle, 1 streaming
u8   mode
u8   gain_code
u8   rate_code
u8   charger_state         0 no input, 1 charging, 2 complete, 3 fault, 4 standby
u8   battery_percent       0..100, or 0xFF unknown
u8   loff_statp            as latched with the most recent sample acquired
u8   flags                 bit0 usb_present, bit1 buffer_high_watermark
u16  dropped_total         samples acquired but never delivered this power-on, saturates
u16  buffer_fill           samples currently buffered on the device

Notified on any state change and at about 1 Hz while connected.

8. Time sync (informative)

Device time is monotonic and free running. The converter's clock and the host's clock drift relative to each other by tens of parts per million, so a host that needs alignment better than about 10 ms re-syncs every few minutes:

  1. Note host time T1, write TIME_SYNC.
  2. The device captures Td at receipt and returns it.
  3. Note host time T2 at the response. Estimate offset ≈ (T1+T2)/2 − Td with uncertainty ±(T2−T1)/2. Regress offset over several syncs to estimate skew.
  4. Map any sample: host_time ≈ device_time × (1 + skew) + offset.

Multiple devices each sync independently to the same host clock, which puts them on one timebase at millisecond alignment without wires.

9. Update service

The Update service carries three kinds of transfer: an application image, a weights image, and a user head. Application and weights images are produced by IntoMind, signed, and encrypted. A host carries them as opaque bytes and needs no key. Heads are plain blobs in the format of section 11, made by anyone.

9.1 Rules

9.2 Update Control (write, indicate)

Request [op, ...], response [op, status, payload?].

Status codes:

CodeMeaning
0OK
1invalid argument or malformed request
2unsupported (no such target on this device)
3busy: streaming, or a transfer is already active
4no transfer in progress
5flash error
6verification failed, see verify_result

Operations:

OpCommandRequestResponse payload
0x01STARTu8 target, u8 slot, u32 total_len, u8[8] transfer_idu8 target_slot, u16 chunk_max, u32 resume_offset
0x02QUERYu8 state, u32 offset, u32 crc32
0x03FINISHu8 verify_result
0x04ACTIVATE
0x05ABORT

verify_result:

ValueMeaning
0verified
1length differs from total_len
2malformed envelope or image header
3target mismatch between START and the image
4slot mismatch: the image is linked for the other slot
5content hash mismatch
6signature invalid
7image security counter is lower than the installed image's
8head dimensions do not match this device
9flash write failure
10unknown key id

9.3 Update Data (write without response)

Each write appends its bytes to the transfer. No framing, at most chunk_max bytes per write. The link layer delivers writes in order and without loss. A host paces itself with QUERY, for example every 32 writes, and compares offset and crc32 with its own count.

9.4 Image envelope

An application or weights image travels inside an envelope whose first 32 bytes are plain and describe the payload. The remainder is encrypted and opaque to the host.

offset type    field
0      u8[4]   magic            "IMUP"
4      u8      envelope_version 1
5      u8      target           1 application, 2 weights
6      u8      slot_link        application: 0 = built for slot A, 1 = built for slot B. 0xFF for weights
7      u8      key_id
8      u8[16]  nonce
24     u32     plain_len        length of the encrypted payload in bytes
28     u8[4]   reserved
32     ...     payload          plain_len bytes

A release of the application ships as two envelopes, one per slot_link. The host reads target_slot from START and sends the matching one. The device rejects a mismatch with verify_result 4 before any flash is written.

10. Predictions (notify)

Model outputs, one notification per window. Present in the GATT table on every device with a model runtime. Notifications flow only while streaming, with predictions enabled, a ready model, and a selected head.

offset type    field
0      u8      packet_type        0x02 = prediction
1      u8      flags              bit0 gap_in_window
                                  bit1 duty_reduced (the device skipped windows to stay within its compute budget)
                                  bit2 leadoff_in_window
2      u8      head_slot
3      u8      n_outputs
4      u32     sample_index       raw stream index of the window's first sample
8      u64     device_time        device time of that sample
16     u16     window_samples     window length in raw samples at the current rate
18     u8[8]   head_id
26     u8      input_source       1.1: what the window was taken from, 0 the stream, 1 the natural signal, 2 the model's own chain. A 1.0 device sends 0
27     u8      reserved
28     f32[n]  outputs            n_outputs little-endian IEEE 754 values

11. Heads

A head is weights, never code. It maps the model's embedding to a small number of outputs.

The embedding e is a vector of model_embed_dim signed 16-bit integers in units of 1/4096. The runtime computes the embedding in floating point and quantizes it at that fixed scale before running a head. The scale is part of this contract: changing it would change every head ever trained. The sdk runs the same runtime on the host and produces the same integers, which is what lets a head trained on a host behave identically on the device.

Head blob, little-endian:

offset type            field
0      u8[4]           magic            "IMHD"
4      u8              format_version   1
5      u8              kind             1 = linear
6      u16             in_dim           must equal model_embed_dim
8      u16             out_dim          1..head_max_outputs
10     u8[16]          name             UTF-8, zero padded
26     u8[6]           reserved         zero
32     i8[out_dim×in_dim] weights       row major, one row per output
       i32[out_dim]    bias
       f32[out_dim]    scale
       u8[32]          sha256           over every preceding byte

12. Security

13. Power-on defaults

SettingDefault
rate500 SPS (rate_code 5)
gain24 (gain_code 6)
modenormal
lead-offoff
samples per packetthe largest count that fits the negotiated MTU
streamingoff
predictionsoff
active headslot 0 when a ready model has a built-in head, else none
processing chainthe device's default, section 16, unless a host's chain was persisted
model inputthe stream
bias driveoff, on every device

14. Reserved numbers

Never reused for another meaning. Production firmware answers status 2 to the opcodes.

KindNumbersNote
Control opcodes0x12, 0x13, 0x31, 0x70, 0x71earlier development firmware
Control opcodes0x60 to 0x6Ffactory and bench builds
Characteristic fill0x0007earlier development firmware
Packet types0x01 EEG data, 0x02 predictionassigned

15. Conformance

The reference codec for every message in this document is a small library that both the firmware and the host tooling build unchanged, so an encoder on one side and a decoder on the other are the same code. Its test vectors are the conformance suite for any independent implementation. A host implementation is conformant when it decodes every vector to the documented fields and refuses every malformed vector.

The vectors are published as one JSON file, emitted by the firmware from the codec it runs. Every message kind in this document appears there, and so does a set of malformed messages for each kind, each with the reason it is refused: truncated, invalid, or reserved.

Two rules govern the file itself, so that a reader in any language gets the same answer from it.

16. Processing

New in 1.1. A device that sets capability bit 10 runs a processing chain on its signal and offers these operations.

16.1 Chains, stages, and classes

A chain is an ordered list of stages. Each stage names a kind from the device's catalog and carries up to four sixteen-bit parameters whose units the kind defines. The empty chain is the natural signal. Every kind belongs to a class:

ClassValueWhat it does to the signal
map0keeps the rate and the kind of signal; map stages layer freely
representation change1replaces the signal with something else, at its own rate
detector2adds annotations and passes the signal on

This version defines three kinds, all map stages:

KindValueParameters, in tenths of a hertzRule
high-pass1cornerat least 1
low-pass2corner0 means the automatic corner at four tenths of the sample rate
notch3low_edge, high_edgea band, high above low, any band

A corner or band edge at or above nine tenths of the Nyquist rate is refused, as is a high-pass at or above the low-pass. The catalog states how many instances of a kind a chain may hold. Kinds of the other two classes will be added by later minors; a device lists in its catalog only what it runs, and refuses a chain that names anything else.

Chain descriptor:

u8      n_stages           0 is the natural signal
per stage:
u8      kind
u8      n_params
u16[n]  params             little-endian

At most 12 stages, at most 4 parameters per stage. A descriptor that says more stages or parameters than it carries is truncated; a stage of kind zero, more than four parameters, more than twelve stages, or bytes after the last stage is invalid.

Catalog (GET_PIPELINE_CATALOG payload): u8 n_kinds, then records of 12 bytes:

u8     kind
u8     class
u8     n_params
u8     max_instances
u8[8]  name               ASCII, zero padded

16.2 What a chain does to the stream

Only the chain's output streams. Each stage runs in the converter's own count domain and the result is rounded back into twenty-four bits, so the scaling rule of section 5 applies unchanged, and the timestamp of a processed sample is the timestamp of the natural sample it corresponds to. Changing the chain while streaming is refused, so one epoch has one chain; a host reads GET_PIPELINE at every stream start and stores the answer with the recording. A stream restart, as ever, is a flagged break.

16.3 Defaults and persistence

The chain is on from the first boot. The device's default is a 0.5 Hz high-pass, the mains bands the rate can represent (both mains fundamentals and their second and third harmonics, four hertz wide, until a host removes the ones that do not apply), and the automatic low-pass. The bands are 48 to 52, 58 to 62, 98 to 102, 118 to 122, 148 to 152 and 178 to 182 Hz. At 250 samples a second the last three reach past nine tenths of the Nyquist rate, 112.5 Hz, so the default there holds three bands. The automatic low-pass sits at 100, 200 and 400 Hz at 250, 500 and 1000 samples a second. A notch places its null at the middle of its band and runs as two sections, so mains 0.03 Hz off its nominal frequency is more than 60 dB down. The chain in force persists on the device across power cycles, so every host sees the same instrument. origin in GET_PIPELINE says whether the chain in force is the device's default, which the device recomposes at every rate change, or a host's, which binds the rate as section 6 describes.

16.4 The model's input

The model follows the stream unless a host points it elsewhere with SET_PREDICTION_INPUT: at the natural signal, or at a chain of its own, run beside the stream's on the same natural samples, without touching the stream. The setting holds until changed or until predictions are turned off. Every prediction carries its input_source. The model declares the signal classes it takes in input_classes of GET_MODEL_INFO, and a chain for the model is refused when its output class is not among them; nothing in the contract fixes what a model may consume.

16.5 Bias

The bias amplifier is off at power on, on every device. A device that sets capability bit 11 lets a host set it off, on, or loop open, and read the bias output's mean, spread, minimum and maximum in millivolts. On a device without the bit both operations answer status 2.