LoopIT

Designing the interface block

neuroConn GmbH

2026-06-05

Designing the interface block

The interface {} block is your script’s public face. Every field you declare there becomes a parameter the host can read and write over the JSON parameter server, appears in discovery, and is the variable your script reads and writes by name. Designing it well means the host side of your experiment is self-describing: valid ranges are enforced by the device, units are declared, and modes are explicit.

This guide builds an interface up step by step — from a single boolean to a multi-mode parameter — with each step a complete, deployable script.

A single boolean

The smallest useful interface is one flag. Sizes are declared up front: 1w is one 32-bit word, 1b one bit. The whole interface must total a whole number of words, so pad the rest with reserved:

1w interface {
  1b bool enable;
  31b reserved;
} ral;

script {};

That is already a working contract. The host sets it with {"ral": {"0": {"enable": true}}}, discovery reports it, and the script reads it as ral.enable. reserved is padding only — it is not exposed to the host.

Sized integers

Numbers are unsigned or signed, with any width up to 32 bits. A field may not cross a word boundary, so pack related fields into a word and pad the remainder:

1w interface {
  1b bool enable;
  8b unsigned level;
  8b signed offset;
  15b reserved;
} ral;

script {};

If the declared widths do not add up to the interface size, the compiler rejects the script with E103 and tells you the required and used bit counts — make the declaration and the contents agree.

Valid ranges

So far the host could write any value that fits the width. A {valid = ...} set turns the field into a guarded parameter: the device rejects out-of-range writes at the JSON interface, before your script ever sees them. The set is a parenthesised list of single values and [low:high] (or [low:step:high]) ranges:

1w interface {
  16b unsigned {valid = (0, [1:2000])} duration;
  16b reserved;
} ral;

script {};

Here duration accepts 0 (meaning “off”, say) or anything from 1 to 2000. Put your safety limits in valid rather than in script logic — the host learns them from discovery, and they hold even while the script is being reloaded.

Units

Fields are integers, so fractional quantities are expressed by declaring what one count means. {unit = 0.001 A} says the field counts milliamperes; a host that reads the discovery information can render 1500 as 1.5 A:

1w interface {
  16b unsigned {unit = 0.001 A, valid = ([0:5000])} amplitude;
  16b reserved;
} ral;

script {};

This is the same convention the built-in modules use (and the function library’s x1000 scaling follows the same idea — see the language reference). Combine unit and valid in one brace, separated by a comma.

Flags: emit, protected, persistent

Three flags shape how a field behaves beyond plain read/write:

  • emit — stream the field’s value continuously over LSL, so it can be recorded.
  • protected — the host can read it, but only the script can write it. Use this for results.
  • persistent — the value survives a script reload, so one script can hand a value to its successor.

A field can carry several flags. This script publishes a result the host can watch but not corrupt:

2w interface {
  1b bool enable;
  31b reserved;
  1w emit protected signed heart_rate_x1000;
} ral;

script {
  if (ral.enable) :
    72000 -> self.heart_rate_x1000;
  fi;
};

(There is also hidden, which keeps a field out of discovery while leaving it usable by name.)

Arrays

A repeated parameter is an array: one declaration, indexed elements. The width is per element, and the element count must still fit the word rules:

1w interface {
  4b unsigned {valid = ([0:10])} weight[1..8];
} ral;

script {};

The host writes elements by index, and the script reads ral.weight[3] or slices like ral.weight[1..4].

Enums: named states

When a field’s values are states rather than quantities, name them. An enum maps names to integers, and the names appear in discovery — so the host sets a state, not a magic number:

1w interface {
  2b enum {off = 0, ramp_up = 1, hold = 2} phase;
  30b reserved;
} ral;

script {};

In the script an enum reads as its integer value, which pairs naturally with the switch-like if from the scripting guide.

Modes: oneof

The capstone. Sometimes a group of parameters only makes sense together, and which group applies depends on a mode — a sine wave has a frequency, a pulse has a width. A oneof declares exactly that: a selector plus one payload region that is decoded differently per mode. Each mode {} names its own fields and its selector value:

2w interface {
  1w oneof 1w {
    mode {
      1w reserved;
    } off = 0;
    mode {
      16b unsigned {unit = 0.001 Hz} frequency;
      16b unsigned {unit = 0.001 A} amplitude;
    } sine = 1;
    mode {
      16b unsigned {unit = 0.001 s} width;
      16b unsigned {unit = 0.001 A} amplitude;
    } pulse = 2;
  } waveform;
} ral;

script {};

Read it as: waveform is a 1-word selector followed by 1 word of payload (two words in total). In sine mode the payload is a frequency and an amplitude; in pulse mode the same bits are a width and an amplitude; off needs nothing. The mode names and their fields all appear in discovery, so the host knows exactly which parameters each mode takes.

In the script, the selector itself reads as an integer, and a mode’s fields are reached through the mode name with an explicit instance index (ral.0.waveform.sine.frequency — the ral.field shorthand does not reach inside a oneof):

2w interface {
  1w oneof 1w {
    mode {
      1w reserved;
    } off = 0;
    mode {
      16b unsigned {unit = 0.001 Hz} frequency;
      16b unsigned {unit = 0.001 A} amplitude;
    } sine = 1;
  } waveform;
} ral;

script {
  if (ral.0.waveform)
    is 1: ral.0.waveform.sine.frequency -> dio.0.digout_1;
  fi;
};

Design checklist

  • One word at a time. Plan fields so none crosses a word boundary, and pad each word with reserved; the compiler’s E103 tells you when the arithmetic is off.
  • Limits belong in valid. The device enforces them at the JSON interface; the host discovers them.
  • Declare units. A {unit = ...} makes the integer self-describing.
  • Results are protected. Readable, not writable, never corrupted by the host.
  • Watchable values are emit. Anything worth plotting or recording should stream over LSL.
  • States are enums, mode-dependent parameter sets are oneofs. Names beat magic numbers, and the structure shows up in discovery.

Next steps

LoopIT documentation · neuroConn