This page is version 1.0 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.0

Status: frozen 2026-09-19. 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.

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 raw stream is never altered. The device acquires, timestamps, buffers, and streams the converter's samples unchanged. No filtering, scaling, or resampling happens on the raw stream. The on-device model of section 10 consumes a private copy of the stream and is additive: turning it on or off changes nothing about the raw data.
  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
10 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. 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     reserved

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[2]   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

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.