Skip to content
View as Markdown

Writing Muxit connectors ​

A reference for AI assistants. It is self-contained: you can write a correct connector from this page alone. Read Rules the runtime enforces before anything else, and finish with the checklist.

A connector is one JavaScript file that brings one device into Muxit. It names the backend that talks to the device, says how the device is connected, and can add computed properties, helper methods, safety limits and instructions for Muxit's own AI. Scripts, dashboards and the AI then use the device by the connector's name: the file workspace/connectors/psu.js is the connector psu, reached from a script as connector("psu").

The deliverable ​

  • One file, workspace/connectors/<name>.js. The file name without .js is the connector name; use lowercase letters, digits, - and _ (psu, bench-dmm, arduino_env).
  • An ES module with a single export default { … }. Plain JavaScript, no TypeScript.
  • It runs in a sandboxed V8 engine inside the Muxit server: not Node.js, not a browser. There are no npm packages and no Node or browser APIs.
  • Give the user the whole file, its path, and the values only they can fill in (serial port, IP address), called out.

Rules the runtime enforces ​

Breaking any of these either stops the file from loading or makes it misbehave quietly.

  1. Pick exactly one backend keyword: protocol: (a built-in wire protocol: "Scpi", "LineText", "BinaryStream"), driver: (a driver that owns its connection), or engine: (compute over another connector). Leave all three out only for an in-memory Virtual connector.
  2. Top-level keys are fixed. The recognised keys are driver, protocol, engine, config, methods, properties, poll, ai, hide, expose, safety, allowedConnectors, allowedHosts. Any other top-level key (connection, source, a driver option) is handed to the driver as configuration, and config: { … } overrides it. In a file without a backend keyword, an unrecognised key is a load error.
  3. These strings may not appear anywhere in the file, comments and string literals included: require(, import(, eval(, Function(. The loader rejects the file on a text match. Do not write import statements either: a default import (import x from "…") is an error, and the named form is stripped and does nothing — everything you can use is already a global.
  4. Inside a connector, every driver.<name> read and every driver.<action>(…) call returns a Promise. Make the getter or method async and await each one. Without await, arithmetic gives NaN and template strings show [object Promise]. A write, driver.<name> = value, starts immediately but cannot be awaited — see The driver object.
  5. driver has only the names the backend declares. Anything else throws ReferenceError: Driver has no property or action '<name>'. Your own custom properties and methods are not on driver.
  6. There is no this and no this.state. Keep state in module-level let variables above export default.
  7. Return plain values — numbers, strings, booleans, null, arrays, plain objects. Never { value, unit }: the unit goes in the description (or unit field), and dashboards and scripts expect a bare number.
  8. Every readable property the backend declares is polled automatically, each on its own loop every 200 ms (server setting polling.connectorRate). The poll key only switches polling on for your custom properties. A backend property stops being polled only when a custom property of the same name replaces it, so keep protocol schemas to the properties the user needs.
  9. Never invent device commands, line formats or register offsets. Take them from the manual or the user, and say which ones you could not verify.

Choose the backend ​

The device…BackendSection
answers SCPI (*IDN? works): benchtop PSUs, DMMs, scopes, generators, loads — Rigol, Keysight, Siglent, Tektronix, R&S, Keithley, BK Precision, Owon…protocol: "Scpi"Scpi
prints text lines (TEMP: 25.5, V=12.0): Arduino and ESP32 sketches, hobby supplies, loggers, anything with a serial consoleprotocol: "LineText"LineText
sends or answers fixed-layout binary frames: Modbus RTU, custom binary sensorsprotocol: "BinaryStream"BinaryStream
is a camera, file store, MQTT broker, notifier, label printer, or has an installed Muxit driver (Fairino robot, Avantes spectrometer, G-code machine…)driver: "<Name>"Drivers
is processed from another connector's output (object detection on a camera feed)engine: "<Name>"Engines
does not exist: shared values for dashboards and scriptsno keywordVirtual
fits none of these: vendor SDK, stateful or multi-step protocola new driverWriting Drivers

Use SCPI when the vendor documents SCPI commands, even if the cable is USB or serial. Use LineText for text that is not SCPI. BinaryStream is only for byte-level frames.

File anatomy ​

js
// workspace/connectors/bench_psu.js
let lastSetpoint = null;                       // module-level state, if you need any

export default {
  protocol: "Scpi",                            // 1. backend: protocol | driver | engine
  connection: { type: "tcp", host: "192.168.1.50", port: 5025 },   // 2. transport (protocols only)

  config: {                                    // 3. options for the backend
    terminator: "\n",
    schema: {                                  //    protocols: what the device understands
      properties: { voltage: { cmd: "VOLT", type: "float", unit: "V", description: "Set voltage" } },
      methods:    { beep: { cmd: "SYST:BEEP", description: "Beep" } },
    },
  },

  properties: { /* computed properties — see Custom properties */ },
  methods:    { /* helper methods — see Custom methods */ },
  poll:       [ /* names of custom properties to poll */ ],

  safety: { limits: { voltage: { min: 0, max: 30 } } },     // 4. hard limits, confirmations
  ai: { instructions: "Bench PSU, 0–30 V. Confirm before enabling the output." },  // 5. for Muxit's AI
};

connection: — how the bytes move ​

Protocol connectors need a top-level connection: block. Drivers usually take their own options in config instead — see the driver's reference.

type must be "serial", "tcp" or "usbtmc"; anything else fails to load.

serial

FieldDefaultNotes
port—"COM4" on Windows, "/dev/ttyUSB0" / "/dev/ttyACM0" on Linux, "/dev/tty.usbserial-XXXX" on macOS. Required.
baudRate9600Must match the device.
dataBits8
parity"none""none", "odd", "even", "mark", "space".
stopBits"1""1", "1.5", "2".
flowControl"none""none", "xon-xoff", "rts-cts", "both".

tcp

FieldDefaultNotes
host"127.0.0.1"IP address or host name.
port50255025 is the usual raw-SCPI (LXI) port.
timeoutMs5000Connect timeout.

usbtmc — USB instruments that enumerate as USB Test & Measurement class. Works with Scpi only; LineText and BinaryStream never receive data over it.

FieldDefaultNotes
vendorId, productId—Required. Number (0x1AB1) or string ("0x1AB1").
serial—Pick one of several identical instruments by serial number.
interfaceNumber0
endpointNumber1Bulk OUT endpoint; IN is `0x80

On Windows a USBTMC instrument needs the WinUSB driver bound (Zadig); on Linux, a udev rule.

Protocol: Scpi ​

For instruments that speak SCPI. You declare each property once — the command and its type — and the protocol does the query, the write and the parsing.

config options

OptionDefaultMeaning
terminator"\n"Appended to every command; a reply ends at the first terminator (a trailing \r is stripped). Some instruments need "\r\n" or "\r".
timeoutMs5000How long a query waits for its reply.
commandIntervalMs0Minimum gap between any two commands. Set 20–100 for slow instruments that drop commands.
autoConnectfalseWhen true, sends *IDN? at start-up to fill the identity property. The transport opens either way.
schema—{ properties, methods }, below.

schema.properties — one entry per value:

js
voltage:          { cmd: "VOLT",       type: "float", unit: "V", description: "Programmed voltage" },
output:           { cmd: "OUTP",       type: "bool",             description: "Output enable" },
measured_voltage: { cmd: "MEAS:VOLT?", type: "float", unit: "V", description: "Measured voltage" },
FieldMeaning
cmdRequired. Without a trailing ?, the property is read with cmd + "?" and written with cmd + " " + value. With a trailing ?, the command is sent as written and the property is read-only.
type"float", "int", "bool" or "string" (the default).
unit, descriptionShown in the UI and to Muxit's AI.
readonlytrue makes a property without ? read-only.

No other fields exist. min, max, enum, scale, format and details are ignored — ranges belong in safety.limits. Parsing on read: float and int must parse as numbers or the read fails; bool is true for 1, ON or TRUE and false for anything else. On write, bool is sent as ON / OFF — if the instrument wants 1 / 0, declare the property as type: "int".

schema.methods — commands that do something and return nothing:

js
beep:   { cmd: "SYST:BEEP", description: "Beep once" },
save:   { cmd: "*SAV", arg: "int", description: "Save settings to slot N" },   // psu.save(3) → "*SAV 3"

arg is a type ("float", "int", "bool", "string") or { type, name }. The method sends cmd or cmd + " " + value and returns "OK"; it never reads a reply. Always pass the argument a method declares: without it a script call sends a placeholder (0, OFF or nothing) and a call from connector code fails. Do not declare an arg the command does not take.

Built in — always present, do not redeclare:

NameKindWhat it does
connected, identity, lastCommand, lastResponse, queryCountread-only propertiesStatus; no I/O.
connect(), disconnect()actionsOpen / close the transport. connect also queries *IDN?.
identify()action*IDN?
reset()action*RST
clear()action*CLS
query(command)actionSend any command, return the reply line as a string.
send(command)actionSend any command, no reply.

Do not name schema entries after the built-ins: a schema property called identity is never queried, and a schema method called query or send hides the raw one.

What the schema cannot express — use a custom property or method with driver.query:

  • Queries with parameters (MEAS:VOLT? CH1, :MEAS:ITEM? VPP,CHAN1). The schema always sends cmd + "?".
  • One reply carrying several values (U=12.00 I=0.103, 5.012,0.103,12.4). Never map several properties to the same cmd. Query once and parse — see the multi-value example.
  • Multi-step sequences, or commands whose reply needs custom parsing.

I/O cost. Every schema property is queried on every poll cycle — five properties at the default 200 ms is 25 queries a second — and the protocol has no cache: each await driver.voltage inside your own code is one more query. All queries share one line: a query the instrument never answers holds it for the full timeoutMs on every poll cycle and delays every other property, and two timeouts in a row trip a breaker that fails all queries until the instrument answers again. So declare only properties the user wants, and make any command without a query form a method, never a property.

The legacy driver: "GenericScpi" with a flat config and a config.scpi block still loads; do not write new connectors in that shape.

Protocol: LineText ​

For devices that print lines of text. Each property recognises a line and keeps the last value it saw; reading a property never sends anything.

config options: terminator (default "\n"; a trailing \r on received lines is stripped), timeoutMs (default 5000, used by query), schema: { properties, methods }.

schema.properties

FieldMeaning
prefixCase-sensitive "line starts with". The rest of the line, trimmed, is the value. "TEMP: " matches TEMP: 25.5.
matchA regular expression string (no flags), when the line needs structure. Backslashes are doubled inside a JS string: "^V=([-+]?\\d+(?:\\.\\d+)?)$". An invalid regex stops the connector loading.
groupCapture group holding the value, default 1; 0 is the whole match. A match with no capture group never updates.
type"float", "int", "bool", "string" (default). float tolerates trailing text (25.5 C → 25.5); bool is true for 1, ON, TRUE. A value that fails to parse becomes null.
unit, descriptionLabels.

Give each property a prefix or a match; with neither it stays null. One line can update several properties. All schema properties are read-only.

schema.methods — { cmd, arg?, description }. Sends cmd or cmd + " " + value plus the terminator and returns "OK". bool arguments are sent as ON / OFF.

Built in: properties lastLine (every received line) and connected; actions send(command) and query(command). query sends a line and returns the next line received, whatever it is — on a device that streams lines unprompted, that may be a streamed line rather than the answer. Do not name schema methods send or query.

Received bytes are decoded one byte per character, so only plain ASCII survives intact.

Protocol: BinaryStream ​

For devices that exchange binary frames. You describe how to find a frame in the byte stream, where each field sits in it, and — for request/response devices — the request to send.

schema.framing — byte strings are hex; spaces, _ and - are ignored ("01 03 00 00").

FieldDefaultMeaning
header—Bytes every frame starts with; used to resync. Never on its own — combine with size or a length field, or no frame is ever delivered.
size—Fixed total frame length in bytes (header included).
lengthOffset, lengthWidth—Where the length byte(s) sit. Frame length = lengthOffset + lengthWidth + <length value> (+2 with Modbus CRC). Use both or neither.
lengthEndian"little""little" or "big".
crc"none""modbus" appends a CRC-16 to everything sent and checks it on received frames (only with size or length framing). Write commands without the CRC. Any other value means none.
quietMs50Without header, size or a length field, a frame ends after this many ms of silence.
request—Hex bytes sent to ask for a fresh frame. Required for devices that only answer when asked (Modbus).
maxStaleMs100A read older than this sends request again. Set it to at least 200 (the poll interval) for about one request per poll cycle.

schema.properties

FieldDefaultMeaning
offset0Byte offset from the start of the frame, header included.
width—Bytes: 1, 2 or 4 (8 for float64). Required — without it the value is always 0.
type"uint""uint", "int" (two's complement — use it for anything that can go negative), "float" (width 4 = float32, 8 = float64), "bool".
endian"little"Modbus registers are "big".
scale1Multiplier, as a number (0.1, not "0.1").
unit, descriptionLabels.

Unsigned 4-byte values of 2³¹ and above decode as negative numbers; avoid relying on them. All schema properties are read-only. Without request, a read returns the last frame received; with it, a stale read sends the request and waits for the reply.

schema.methods — { command, argOffset?, argWidth?, argType?, argEndian?, description }. command is a hex template; with argOffset and argWidth, the argument is packed into it at that offset. Arguments are packed as integers only (a float is truncated). Returns "OK".

Built in: properties connected and lastFrame (hex string); actions sendHex(hex) and queryHex(hex) (returns the next frame as uppercase hex).

Using a driver ​

driver: "<Name>" picks a backend that owns its connection. Driver names are case-insensitive. Each driver has its own config options and its own properties and actions; take them from the driver's reference page or its starter template (shown when the user adds a connector for that driver in Muxit). Do not guess option names — an unknown option is silently ignored.

DriverWhat it isReference
TestDeviceSimulated device for trying things out (temperature, counters, streams, video).test-device
WebcamUSB / built-in camera: live stream, snapshot(), recording.webcam
OnvifIP camera: stream, snapshot, PTZ.onvif
FileAccessRead and write files in a workspace folder.file-access
MqttBridgePublish and subscribe to an MQTT broker.mqtt-bridge
WebhookNotifierHTTP notifications: ntfy, Discord, Slack, generic JSON.webhook-notifier
EmailNotifierE-mail over SMTP.email-notifier
LabelPrinterLabel printing from templates.label-printer
VirtualIn-memory store; usually written without a keyword.Virtual
installed driverse.g. Fairino, Avantes, AudioSynth, Midi, Gcode, Pythonthe driver's own page, or its template
js
// workspace/connectors/webcam.js
export default {
  driver: "Webcam",
  config: { device: 0 },                  // option names come from the driver's reference
  ai: { instructions: "Bench camera looking at the fixture." },
};

Wrap a driver's raw API in friendlier methods, and use hide to keep the raw items out of the UI and Muxit's AI (they stay callable from scripts):

js
export default {
  driver: "Fairino",
  config: { ip: "192.168.58.2" },
  hide: ["moveL", "moveJ"],
  methods: {
    moveTo: {
      fn: (x, y, z) => driver.moveL({ pose: [x, y, z, 180, 0, 90] }),
      description: "Linear move to x, y, z in mm, tool pointing down",
    },
  },
};

Engines ​

An engine processes another connector's output instead of talking to hardware. Name the source connector with source:

js
export default { engine: "Vision", source: "webcam" };

Virtual (no hardware) ​

A file with no driver, protocol or engine key — and only the recognised top-level keys — is an in-memory store. State lives in module-level variables and is lost on restart. Use it for setpoints a dashboard slider adjusts and a script reads, status text, counters and flags.

js
// workspace/connectors/store.js
let setpoint = 20;
let status = "Idle";

export default {
  properties: {
    setpoint: { get: () => setpoint, set: (v) => { setpoint = Number(v); }, description: "Target (°C)" },
    status:   { get: () => status,   set: (v) => { status = String(v); },   description: "Status line" },
  },
  methods: {
    reset: [() => { setpoint = 20; status = "Idle"; }, "Back to defaults"],
  },
  poll: ["setpoint", "status"],   // so changes made inside reset() reach dashboards
};

A direct write (store.setpoint = 25 from a script or a dashboard) is broadcast at once. A value changed inside a method is only noticed by polling, hence the poll list.

Custom properties ​

Computed or wrapped values. Three equivalent forms:

js
properties: {
  uptime: () => Date.now() - startedAt,                                  // bare getter
  power:  [async () => (await driver.voltage) * (await driver.current),  // [getter, description, options]
           "Output power (W)", { poll: true }],
  target: {                                                              // full form
    get: () => target,
    set: (v) => { target = Number(v); },
    description: "Target temperature (°C)",
    poll: true,
  },
},
  • get is required. An entry with only set is ignored.
  • No set means read-only in the UI and for Muxit's AI.
  • poll: true (or the name in the top-level poll: [...]) re-reads it every 200 ms and pushes changes to dashboards. Only poll what a dashboard shows live; every poll runs your getter, and every await driver.x in it is a device query.
  • Getters run on every read from a script, dashboard or AI. Keep them quick.
  • There is no type or unit field. The type is inferred from the value; put the unit in the description.
  • Setter writes are queued: the UI shows the new value at once, the setter runs at most once per 100 ms with the latest value, and an error it throws only reaches the server log. Writing the value a property already has does nothing.
  • A custom property with the same name as a backend property replaces it (and the backend property is then only polled if you set poll: true). Inside it, driver.<name> still reaches the backend property.

Custom methods ​

Helper actions callable as connector("psu").rampTo(12). Three equivalent forms:

js
methods: {
  home: () => driver.home(),                                        // bare function
  park: [async () => { await driver.send("G0 Z5"); }, "Raise Z"],   // [fn, description]
  rampTo: { fn: (target, stepMs) => rampTo(target, stepMs),          // { fn, description } (or `call`)
            description: "Ramp voltage to target (V) — rampTo(target, stepMs = 100)" },
},

How arguments arrive. A script call dev.rampTo(12, 50) calls fn(12, 50); one argument arrives as that value; no arguments as fn(). A single array is spread: dev.load([1, 2, 3]) calls fn(1, 2, 3). Muxit's AI and MCP clients pass one object — { "target": 12 } arrives as fn({ target: 12 }) — because named arguments are not mapped onto parameters. Accept both:

js
fn: (target, stepMs) => {
  if (typeof target === "object" && target !== null) ({ target, stepMs } = target);
  return rampTo(Number(target), Number(stepMs ?? 100));
},

How the argument list is shown. Muxit reads the parameter names from the function source to show the arguments in the Connectors panel and to Muxit's AI — but only for function (a, b), (a, b) => and a =>. An async (a) => or async function shows no arguments, and the panel then runs it with none. So keep the exposed function a plain arrow and put asynchronous work in a module-level async function, as rampTo above does. Describe the expected arguments in the description as well.

Return a plain value (or nothing). Throw new Error("…") to fail — the message reaches the caller. Keep methods short — seconds, not minutes: each call holds the connector's engine, so other custom calls and custom polls on the same connector wait for it. Long sequences belong in a script.

A method with the same name as a backend action replaces it.

The driver object ​

Inside getters, setters and methods, driver is the backend.

You writeWhat happens
await driver.voltageLive read from the device (not the poll cache). Without await you get a Promise.
driver.voltage = 5Starts the write and returns at once; you cannot wait for it, and an error in it is not reported to you.
await driver.reset()Calls the action and waits for its result.
driver.foo (not declared by the backend)Throws ReferenceError.
  • When a later step depends on a write having happened, use an action you can await instead. On Scpi: await driver.send(\VOLT ${v}`)`.
  • Action arguments inside a connector are not mapped to names. Pass driver actions an object with the declared argument names: await driver.moveL({ pose: [x, y, z] }). Scpi, LineText and BinaryStream also accept a bare value: await driver.query("SYST:ERR?").
  • Calls made through driver skip Muxit's safety gate. A method that writes driver.voltage = 100 is not stopped by safety.limits. Check ranges in your own code.
  • driver holds the backend's names only — never your own custom properties or methods. To reuse logic, write a module-level function and call it from both places.

Globals inside a connector ​

GlobalNotes
driverThe backend, above.
log.info(…), log.warn(…), log.error(…), log.debug(…); console.log / warn / errorServer log, tagged with the connector name.
await delay(ms)Async sleep, capped at 30 000 ms. It must be awaited, inside an async function.
timestamp()ISO 8601 UTC string.
connector(name)Another connector — only when listed in allowedConnectors. Returns Promises like driver.
"$ENV{NAME}"A string that is exactly this is replaced with the environment variable NAME — for API keys and passwords. "Bearer $ENV{TOKEN}" is not replaced.
createTcpTransport(host, port, opts), createSerialTransport(path, opts)Raw transports, for drivers that take a pre-built config._transport. Not needed with protocol:.
emit(event, data)Publishes connector:<this connector>.<event>. Scripts receive it with on("connector:<name>.<event>", …) or waitFor(…). Every call is delivered, even with unchanged data.

Plus standard JavaScript (Math, JSON, Date, Map, RegExp, Promise, …). Not available: setTimeout, setInterval, fetch, require, process, Buffer, eval, and the script-only globals on, stream, ai, say, ask and script.

Top-level code runs at load time without these globals. The file is evaluated once to read its configuration before any device exists, so code outside functions must not call driver, connector, log, delay or timestamp. Declare variables and helper functions at the top level; call things only inside getters, setters and methods.

Talking to other connectors ​

Connector files are denied access to other connectors unless they list them:

js
export default {
  driver: "Virtual",
  allowedConnectors: ["psu", "dmm"],
  properties: {
    efficiency: {
      get: async () => {
        const pin = await connector("psu").power;
        const pout = await connector("dmm").power;
        return pin > 0 ? pout / pin : null;
      },
      description: "Output / input power",
    },
  },
};

Do not call connector() at the top level (other connectors may not be up yet), and do not call your own connector's custom members through connector("<self>") — that waits on itself forever. Scripts are not subject to allowedConnectors.

Network hosts ​

createTcpTransport may only reach localhost, loopback and private addresses (10.x, 172.16–31.x, 192.168.x, 169.254.x). Anything else — a public address, or any other host name — is refused unless:

  • the user turns on security.allowExternalTransports in workspace/config/server.json (server-wide), or
  • the transport is created inside one of this connector's getters, setters or methods and the host is listed in allowedHosts: ["api.vendor.com"]. A transport built at the top level, such as config: { _transport: createTcpTransport(…) }, is created while the file loads, before allowedHosts applies, so it needs the server-wide setting.

Visibility: hide and expose ​

hide: ["rawCmd", …] removes backend or custom items from the Connectors panel, Muxit's AI and MCP; expose: [...] keeps only the listed items. Use one or the other, never both (load error). Hidden items are still callable from scripts and still polled.

AI instructions ​

js
ai: { instructions: "60 V / 2.5 A bench supply feeding a DUT rated 12 V. Never set more than 13 V. Ask before enabling the output." },

Muxit's own assistant sees this next to the device in every conversation. Put safety rules, units, what the device is connected to, and how to use it there. Keep it to a few sentences.

Safety ​

js
safety: {
  limits:  { voltage: { min: 0, max: 13 }, current: { min: 0, max: 2.5 } },
  confirm: { output: "once", reset: "always" },
  rate:    { voltage: 10 },
},
KeyMeaning
limits{ prop: { min, max, largeChangeFraction? } }. Writes outside min–max are blocked (except at the Unrestricted safety level). A change bigger than largeChangeFraction (default 0.25) of the range asks for confirmation at the Assisted level.
confirm{ item: "once" | "always" | "never" | "typed" | "onLargeChange" } per property or action. always / typed also mark an action as destructive. Any other value is a load error.
rate{ item: n } — at most n operations per second (in practice rounded up: values below 1 allow one per second).
overrideLevelPin this connector to "Observe", "Assisted", "Active" or "Unrestricted" regardless of the global level.

Know the limits of the block:

  • It is enforced only on Muxit's Lab tier. On other tiers it is ignored (with a warning in the server log) and only the global safety level applies. Write it anyway — it documents the safe range and protects Lab users — but also check ranges in your own methods and throw.
  • It applies to writes and actions coming into the connector (scripts, dashboards, AI). Calls your own code makes through driver are not checked.
  • interlock and safeState are accepted but not enforced yet; do not rely on them.
  • Drivers that opt out of the safety gate (Virtual, FileAccess, Webcam, Onvif, WebhookNotifier) skip it entirely, so a safety block on them does nothing.

Loading and reloading ​

Muxit does not watch connector files. After the file is saved, the user clicks Reload in the Connectors panel (or restarts the server). If their workspace has an explicit list of enabled connectors, a new file starts disabled until they tick it in the Connectors panel. A file that fails to load shows the error, with a line number, on its card.

Multi-value replies ​

When one SCPI query returns several values (U=12.00 I=0.103 P=1.236), query once per poll cycle into a module-level variable and parse it in cheap getters:

js
let raw = "";
const field = (key) => {
  const m = raw.match(new RegExp(`${key}=([-+]?[\\d.]+(?:[eE][-+]?\\d+)?)`));
  return m ? Number(m[1]) : null;
};

export default {
  protocol: "Scpi",
  connection: { type: "serial", port: "COM3", baudRate: 9600 },
  config: { terminator: "\r\n" },
  properties: {
    reading: { get: async () => (raw = await driver.query("VAL?")), poll: true, description: "Raw VAL? reply" },
    voltage: { get: () => field("U"), poll: true, description: "Voltage (V), parsed from VAL?" },
    current: { get: () => field("I"), poll: true, description: "Current (A), parsed from VAL?" },
  },
};

Complete examples ​

SCPI bench supply over LAN

js
// workspace/connectors/psu.js
const MAX_V = 30, MAX_A = 3;

async function applySetpoint(volts, amps) {
  if (!(volts >= 0 && volts <= MAX_V)) throw new Error(`voltage must be 0–${MAX_V} V`);
  if (!(amps >= 0 && amps <= MAX_A)) throw new Error(`current must be 0–${MAX_A} A`);
  await driver.send(`APPL ${volts},${amps}`);
  return { volts, amps };
}

export default {
  protocol: "Scpi",
  connection: { type: "tcp", host: "192.168.1.50", port: 5025 },   // ← the user's IP
  config: {
    terminator: "\n",
    timeoutMs: 2000,
    autoConnect: true,
    schema: {
      properties: {
        voltage:          { cmd: "VOLT",       type: "float", unit: "V", description: "Programmed voltage" },
        current:          { cmd: "CURR",       type: "float", unit: "A", description: "Programmed current limit" },
        output:           { cmd: "OUTP",       type: "bool",             description: "Output enable" },
        measured_voltage: { cmd: "MEAS:VOLT?", type: "float", unit: "V", description: "Measured voltage" },
        measured_current: { cmd: "MEAS:CURR?", type: "float", unit: "A", description: "Measured current" },
      },
      methods: {
        save:   { cmd: "*SAV", arg: "int", description: "Save settings to memory slot N" },
        recall: { cmd: "*RCL", arg: "int", description: "Recall settings from memory slot N" },
      },
    },
  },
  properties: {
    power: {
      get: async () => {
        const v = await driver.measured_voltage;
        const i = await driver.measured_current;
        return Math.round(v * i * 1000) / 1000;
      },
      description: "Output power (W), computed",
      poll: true,
    },
  },
  methods: {
    apply: {
      fn: (volts, amps) => {
        if (typeof volts === "object" && volts !== null) ({ volts, amps } = volts);
        return applySetpoint(Number(volts), Number(amps));
      },
      description: "Set voltage (V) and current limit (A) in one step: apply(volts, amps)",
    },
    last_error: [() => driver.query("SYST:ERR?"), "Read the next entry of the error queue"],
  },
  safety: {
    limits:  { voltage: { min: 0, max: MAX_V }, current: { min: 0, max: MAX_A } },
    confirm: { output: "once", apply: "always" },
  },
  ai: { instructions: "Bench PSU, 0–30 V / 0–3 A. Ask before switching the output on or raising the voltage." },
};

Arduino sketch printing text lines

js
// workspace/connectors/env_sensor.js — the sketch prints "TEMP: 23.4" and "HUM=41.0%" lines
export default {
  protocol: "LineText",
  connection: { type: "serial", port: "/dev/ttyACM0", baudRate: 115200 },   // ← the user's port
  config: {
    terminator: "\n",
    schema: {
      properties: {
        temperature: { prefix: "TEMP: ", type: "float", unit: "°C", description: "Last temperature line" },
        humidity:    { match: "^HUM=([-+]?\\d+(?:\\.\\d+)?)%?$", type: "float", unit: "%", description: "Last humidity line" },
      },
      methods: {
        set_led: { cmd: "LED", arg: "bool", description: "LED on/off — sends LED ON / LED OFF" },
      },
    },
  },
  ai: { instructions: "Arduino environment sensor. Values are the last line the sketch printed." },
};

Modbus RTU sensor

js
// workspace/connectors/th_sensor.js — slave 1, holding registers 0 (temp ×10) and 1 (RH ×10)
export default {
  protocol: "BinaryStream",
  connection: { type: "serial", port: "/dev/ttyUSB0", baudRate: 9600 },
  config: {
    timeoutMs: 1000,
    schema: {
      framing: {
        header: "01 03",                  // slave address + function code of the reply
        lengthOffset: 2, lengthWidth: 1,  // byte count follows the header
        crc: "modbus",
        request: "01 03 00 00 00 02",     // read 2 registers from 0; CRC is appended
        maxStaleMs: 250,
      },
      properties: {
        temperature: { offset: 3, width: 2, type: "int",  endian: "big", scale: 0.1, unit: "°C", description: "Register 0" },
        humidity:    { offset: 5, width: 2, type: "uint", endian: "big", scale: 0.1, unit: "%",  description: "Register 1" },
      },
    },
  },
};

Common mistakes ​

MistakeInstead
const v = driver.voltage; return v * 2;async getter, const v = await driver.voltage;
this.state.x, this.driverModule-level variables; the driver global.
require(…), import fs from "fs", fetch(…), setInterval(…)None exist. Polling replaces timers; drivers or connectors replace I/O.
The words require(, eval(, Function( in a commentThe loader rejects the file on a text match; reword the comment.
module.exports = …, export const …One export default { … }.
Two schema properties with the same cmdOne query, parsed — multi-value replies.
A property for a command that has no query form (*RST, OUTP:PROT:CLE)A method.
min, max, scale, enum on a SCPI schema propertysafety.limits, or custom properties.
scale: "0.1" on a BinaryStream fieldscale: 0.1
header with no size or length fieldAdd size or lengthOffset + lengthWidth.
Returning { value: 12.1, unit: "V" }12.1, with the unit in the description.
poll: ["voltage"] to make a schema property updateAlready polled; poll is for custom properties only.
driver.myCustomMethod()Call a module-level helper function.
async (target) => … as a method that takes arguments(target) => helper(target) so the arguments are visible.
Calling log, driver or connector at the top levelOnly inside getters, setters and methods.
Long loops inside a methodA script.
Relying on safety as the only range checkAlso validate in your methods.
Invented SCPI commands or register mapsFrom the manual or the user; flag what is unverified.

Checklist ​

  • [ ] File workspace/connectors/<name>.js, one export default { … }, no import / require, and none of require(, import(, eval(, Function( anywhere in the text.
  • [ ] Exactly one of protocol / driver / engine (or none, for a Virtual store with only recognised top-level keys).
  • [ ] Protocol connectors have a connection block with the right type and the user's port, host or USB ids — called out as values to check.
  • [ ] Every command, line format, register offset and baud rate comes from the manual or the user; anything unverified is listed.
  • [ ] SCPI schema: one cmd per property, ? only on query-only commands, no unsupported fields; commands without a query form are methods.
  • [ ] Every getter or method that reads driver.* is async and awaits each read.
  • [ ] Methods that take arguments are plain arrows (or function), accept positional arguments and a single object, describe their arguments, validate ranges and throw on bad input.
  • [ ] No this, no timers, no Node or browser APIs, nothing but declarations at the top level.
  • [ ] Only properties the user needs, since every readable backend property is polled.
  • [ ] safety.limits for every setpoint with a known safe range, confirm on outputs and destructive actions, and ai.instructions with the device's safety rules.
  • [ ] Other connectors used from this file are in allowedConnectors.
  • [ ] Told the user to save the file, click Reload in the Connectors panel, and enable the connector if it shows as disabled.

Muxit — Hardware Orchestration Platform