Writing Muxit drivers
A reference for AI assistants. It is self-contained: you can write a correct driver from this page alone. Read Before you write a driver and The contract every driver follows first, then the section for your language, and finish with the checklist.
A driver is a backend that talks to one kind of device. On its own it does nothing: a connector selects it with driver: "<Name>", passes it configuration, and gives the device a name. Scripts, dashboards and Muxit's AI then use the properties and actions the driver declares.
Before you write a driver
Most devices do not need one. A connector using a built-in protocol covers SCPI instruments (Scpi), anything that prints text lines (LineText) and fixed-layout binary frames such as Modbus RTU (BinaryStream) — see Writing Connectors. If the user only wants to call some Python functions, the generic Python driver may be enough (see Python connectors).
Write a driver when the device needs a vendor SDK or native library, a Python-only stack (numpy, pyvisa, a vendor wheel), a stateful handshake or session, or a protocol the three built-in ones cannot express.
| Language | Choose it when | Runs as |
|---|---|---|
JavaScript (.driver.js) | Serial or TCP devices with a text or simple binary protocol. Quickest to write; nothing to install. | Sandboxed V8 inside the Muxit server. |
Python (.driver.py) | The device has a Python SDK, or you need numpy / pyserial / pyvisa / sockets. | A separate Python 3.10+ process per connector. Needs Python on the user's machine. |
C# (.dll in a .muxdriver) | A .NET or native vendor SDK, high throughput, or full .NET. | Loaded into the server. Needs the Muxit driver SDK assembly to build. |
The contract every driver follows
Every driver, in every language, has the same shape:
- A name (
MyDevice) — what connectors write indriver: "MyDevice". It may not equal a built-in name (Scpi,Webcam,TestDevice,Virtual, …); comparison ignores case. - A group:
instruments,motion,communicationorutilities. - Properties, each
{ name, type, access, unit?, description, details? }. - Actions, each
{ name, description, args?, details? }. - Streams (optional): names of continuous outputs (lines, frames, spectra) that dashboard widgets subscribe to.
- Lifecycle:
init(config)once when the connector starts (open the connection here, never at load time);get(property),set(property, value),execute(action, args)while running;shutdown()when it stops.
access is exactly "R", "W" or "R/W" — uppercase, with the slash. Every "R" and "R/W" property is read every 200 ms for as long as the connector runs, so get must be cheap: return a cached value, or do one short query. Spellings such as "r" or "rw" are never polled, so their values never reach dashboards on their own.
type is free text used for display and for Muxit's AI; use one of "string", "int", "double", "bool", "double[]", "int[]", "string[]", "object".
description is one line and is always shown — in the UI, in IntelliSense, and in Muxit's AI prompt. details is optional markdown fetched only on demand: put argument enums, units, side effects, timeouts and failure modes there, not in the description.
config is the connector's configuration: the connector's config: { … } plus any other non-reserved top-level keys. Read every option with a default and validate required ones in init, throwing a clear error ('MyDevice needs config.port, e.g. "COM3"').
args arrive as an object keyed by the declared argument names. A script call dev.move(10, 20) arrives as { x: 10, y: 20 } when the action declares x then y; a call with one object passes that object. Declare arguments in the order a caller would naturally pass them.
Errors: throw (or raise) with a message a user can act on; it reaches the script, dashboard or AI that made the call.
Safety: the user's safety gate checks every write and action made on a connector that uses your driver. Third-party drivers cannot opt out — requiresSafetyGates: false is ignored for JavaScript and Python drivers and for unsigned C# drivers.
JavaScript drivers
One file, <name>.driver.js, with a single export default object.
// workspace/drivers/mydevice.driver.js
// Helpers may be declared at the top level, but never *call* host globals (log,
// createSerialTransport, setTimeout) there: the file is also evaluated at scan time without them.
function openSerial(config) {
if (!config.port) throw new Error('MyDevice needs config.port, e.g. "COM3" or "/dev/ttyUSB0"');
return createSerialTransport(config.port, {
baudRate: config.baudRate ?? 115200,
delimiter: config.delimiter ?? "\n",
timeout: config.timeout ?? 5000,
});
}
export default {
meta: {
name: "MyDevice",
version: "1.0.0",
group: "instruments",
description: "Line-based serial instrument with a setpoint, a measurement and a line stream.",
properties: {
connected: { type: "bool", access: "R", description: "Serial port is open" },
setpoint: { type: "double", access: "R/W", unit: "V", description: "Output setpoint",
details: "Writing sends `SETP <v>` and waits up to 2 s for the reply." },
measurement: { type: "double", access: "R", unit: "V", description: "Last measured value" },
},
actions: {
measure: { description: "Take a fresh measurement and return it" },
send: { description: "Send a raw line and return the reply", args: { text: "string" },
details: "Appends `\\n`. Fails after 2 s without a reply line." },
},
streams: ["output"],
},
// State lives on the object; the lifecycle methods are called with `this` bound to it.
_transport: null,
_pending: [],
_setpoint: 0,
_measurement: null,
async init(config) {
this._pending = [];
this._transport = openSerial(config);
this._transport.onData = (line) => { // set before connect()
const waiter = this._pending.shift();
if (waiter) waiter.resolve(line);
__emitStream("output", line); // stream data must be a string
};
if (!this._transport.connected) await this._transport.connect();
},
async get(property) { // polled every 200 ms: cached values only
switch (property) {
case "connected": return this._transport?.connected ?? false;
case "setpoint": return this._setpoint;
case "measurement": return this._measurement;
default: throw new Error(`Unknown property: ${property}`);
}
},
async set(property, value) {
if (property !== "setpoint") throw new Error(`Property '${property}' is read-only`);
const v = Number(value);
if (!Number.isFinite(v)) throw new Error(`setpoint must be a number, got ${value}`);
await this._request(`SETP ${v}`);
this._setpoint = v;
},
async execute(action, args) {
switch (action) {
case "measure": {
this._measurement = Number(await this._request("MEAS?"));
return this._measurement;
}
case "send": return this._request(String(args?.text ?? ""));
default: throw new Error(`Unknown action: ${action}`);
}
},
async shutdown() {
for (const waiter of this._pending) waiter.reject(new Error("Driver shut down"));
this._pending = [];
if (this._transport) { this._transport.onData = null; this._transport.close(); }
this._transport = null;
},
// One request, one reply line. Replies are matched through onData, not transport.query().
_request(text, timeoutMs = 2000) {
if (!this._transport?.connected) return Promise.reject(new Error("Not connected"));
return new Promise((resolve, reject) => {
const waiter = {};
const timer = setTimeout(() => {
this._pending = this._pending.filter((w) => w !== waiter);
reject(new Error(`No reply to '${text}' within ${timeoutMs} ms`));
}, timeoutMs);
waiter.resolve = (line) => { clearTimeout(timer); resolve(line); };
waiter.reject = (err) => { clearTimeout(timer); reject(err); };
this._pending.push(waiter);
this._transport.write(text + "\n");
});
},
};Rules for JavaScript drivers
- The file contains
export defaultand ametaobject. No otherexport, noimport(stripped), and the stringsrequire(andimport(may not appear anywhere, comments included.evalandnew Functionthrow. - Write the lifecycle functions as methods (
async init(config) { … }), not arrow functions — they needthis. metafields read:name,version,description,group,properties,actions,streams. Inactions,argsmaps each name to a type string ({ x: "double" }); arguments have no descriptions, so explain them in the action'sdetails.- Calls to one connector's driver run one at a time: a slow
executedelays thegetpolls behind it. - Returning
undefinedor""reaches the caller asnull.
Globals in a JavaScript driver: log.info/warn/error/debug, console.log/warn/error (server console only), timestamp(), setTimeout, clearTimeout, setInterval, clearInterval, createSerialTransport(path, { baudRate, delimiter, timeout }), createTcpTransport(host, port, { delimiter, timeout }), __emitStream(name, string), and __emitEvent(name, data), which publishes connector:<connector>.<name> for scripts' on() / waitFor() on every call (use it for occurrences, such as a scan, that may repeat the same value). There is no delay (use new Promise((r) => setTimeout(r, ms))), no emit, no Buffer, no fetch, no require, no Node or browser API.
Transports. createSerialTransport honours only baudRate, delimiter and timeout (8 data bits, no parity, 1 stop bit). createTcpTransport may only reach localhost, loopback and private network addresses; anything else needs the user to turn on security.allowExternalTransports in workspace/config/server.json (a connector's allowedHosts does not reach drivers). The transport object has connect(), write(string), close(), connected and onData = (line) => … (one call per received line, split on delimiter); serial transports also have setRawMode(true) with onRawData = (hex, ts) => … (each received chunk as an uppercase hex string) for byte-level protocols. write() sends text as UTF-8, so bytes ≥ 0x80 become two bytes on the wire; to send binary use writeRaw([0xFE, 0xFF, …]) (a byte array, Uint8Array, single number, or a string whose char codes are all ≤ 0xFF), which sends exactly those bytes. connect(), write() and query() return .NET tasks that await does not unwrap: await transport.query(cmd) does not give you the reply, so match replies to requests through onData, as _request does above. A serial connect() has opened the port when it returns (or thrown); a TCP connect() returns before the connection is up, so wait until transport.connected is true, with a timeout.
Python drivers
One file, <name>.driver.py, defining a subclass of muxit_driver.Driver. Muxit provides the muxit_driver package; do not install or vendor it yourself for a loose driver file.
# workspace/drivers/mydevice.driver.py
from __future__ import annotations
from typing import Any
from muxit_driver import Driver, run
class MyDevice(Driver):
META = {
"name": "MyDevice",
"version": "1.0.0",
"description": "Serial instrument via pyserial: setpoint, measurement and a reading stream.",
"group": "instruments",
"properties": {
"connected": {"type": "bool", "access": "R", "description": "Serial port is open"},
"setpoint": {"type": "double", "access": "R/W", "unit": "V", "description": "Output setpoint"},
"measurement": {"type": "double", "access": "R", "unit": "V", "description": "Last measured value"},
},
"actions": {
"measure": {
"description": "Take N readings and return them",
"args": {"count": {"type": "int", "description": "Number of readings (default 1)"}},
"details": "Each reading is also emitted on the `readings` stream.",
},
},
"streams": ["readings"],
}
def __init__(self) -> None:
self._ser = None
self._setpoint = 0.0
self._measurement: float | None = None
def init(self, config: dict[str, Any] | None) -> None:
config = config or {}
import serial # third-party imports go here, never at module level
port = config.get("port")
if not port:
raise ValueError('MyDevice needs config.port, e.g. "COM3" or "/dev/ttyUSB0"')
self._ser = serial.Serial(str(port), int(config.get("baudRate", 115200)), timeout=2)
self.log(f"opened {port}")
def _query(self, command: str) -> str:
self._ser.write((command + "\n").encode())
reply = self._ser.readline().decode(errors="replace").strip()
if not reply:
raise TimeoutError(f"no reply to {command!r}")
return reply
def get(self, property: str) -> Any: # polled every 200 ms: cached values only
if property == "connected":
return bool(self._ser is not None and self._ser.is_open)
if property == "setpoint":
return self._setpoint
if property == "measurement":
return self._measurement
raise KeyError(f"unknown property: {property}")
def set(self, property: str, value: Any) -> None:
if property != "setpoint":
raise KeyError(f"property '{property}' is read-only")
self._query(f"SETP {float(value)}")
self._setpoint = float(value)
def execute(self, action: str, args: dict[str, Any] | None) -> Any:
args = args or {}
if action == "measure":
values = []
for _ in range(int(args.get("count", 1))):
self._measurement = float(self._query("MEAS?"))
values.append(self._measurement)
self.emit("readings", {"v": self._measurement})
return values
raise KeyError(f"unknown action: {action}")
def shutdown(self) -> None:
if self._ser is not None:
self._ser.close()
if __name__ == "__main__":
run(MyDevice)Rules for Python drivers
- The file imports
Driverandrunfrommuxit_driver, definesMETA, and ends withif __name__ == "__main__": run(YourClass). - Action arguments in
METAare dicts:{"count": {"type": "int", "description": "…"}}. A bare string ({"count": "int"}) breaks the driver scan and the driver does not appear. - Import third-party packages inside
init. Muxit runs the module with--scanto readMETAon every start-up, where heavy or missing packages would slow or break the scan. - Never
print(): stdout is the channel to Muxit. Useself.log(message, level="info"),self.emit(stream, data)(any JSON-serialisable data),self.emit_progress(stage, line), or write to stderr. - Methods may be plain or
async def. Requests are handled one at a time;get,setandexecutetime out after 30 seconds,initafter 30 minutes (for first-run installs). - Return JSON-serialisable values; numpy arrays are converted with
.tolist(). - Dependencies go in a
requirements.txtnext to the.driver.py. Muxit creates a virtual environment beside it and installs them on first use. There is no SDK transport helper — usepyserial,socket,pyvisaor the vendor package directly. - Test without Muxit:
python3 mydevice.driver.py --scanprints the parsedMETA;python3 -u mydevice.driver.py --instance devreads JSON-RPC lines such as{"id": 1, "method": "init", "params": {"port": "COM3"}}from stdin. Outside Muxit,muxit_drivermust be importable (PYTHONPATHpointing at a copy of it).
C# drivers
A .NET class library implementing Muxit.Driver.Sdk.IConnectorDriver, shipped in a .muxdriver package. Build it against the Muxit.Driver.Sdk assembly, which is not on NuGet: it ships with Muxit (as a file in the Linux and macOS install folders) and is the drivers/Muxit.Driver.Sdk project in Muxit's source. Reference it without copying it into your output, and never put it in the package — the server always supplies its own copy.
<!-- MyDevice.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MyDevice</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Reference Include="Muxit.Driver.Sdk">
<HintPath>path/to/Muxit.Driver.Sdk.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>// MyDeviceDriver.cs
using Muxit.Driver.Sdk;
using static Muxit.Driver.Sdk.DriverConfig;
[assembly: DriverGroup(DriverGroup.Instruments)] // assembly-level, before the namespace
namespace MyCompany.MyDevice;
public sealed class MyDeviceDriver : IConnectorDriver
{
public string Name => "MyDevice";
public string? Version => "1.0.0";
public string? Description => "Example instrument with a setpoint and a measurement.";
public Action<string, string>? StreamEmitter { get; set; } // required, even without streams
private readonly object _lock = new();
private string _host = "";
private double _setpoint;
public MyDeviceDriver() { } // public, parameterless, no I/O: also constructed at scan time
public Task InitAsync(Dictionary<string, object?>? config)
{
_host = GetString(config, "host", "");
if (_host == "") throw new ArgumentException("MyDevice needs config.host, e.g. \"192.168.1.50\"");
// open the connection here
return Task.CompletedTask;
}
public Task ShutdownAsync() // also called on an instance that was never initialised
{
// close the connection if it is open
return Task.CompletedTask;
}
public IEnumerable<PropertyDescriptor> GetProperties() =>
[
new PropertyDescriptor("setpoint", "double", "R/W", "V", "Output setpoint", Details: "Range 0–30 V."),
new PropertyDescriptor("host", "string", "R", Description: "Device address"),
];
public IEnumerable<ActionDescriptor> GetActions() =>
[
new ActionDescriptor("reset", "Reset to defaults"),
new ActionDescriptor("ramp", "Ramp the setpoint to a target",
[new ArgDescriptor("target", "double", "Target in V"), new ArgDescriptor("rate", "double", "V per second")]),
];
public Task<object?> GetAsync(string property) => Task.FromResult<object?>(property switch
{
"setpoint" => _setpoint,
"host" => _host,
_ => throw new ArgumentException($"Unknown property: {property}"),
});
public Task SetAsync(string property, object? value)
{
if (property != "setpoint") throw new ArgumentException($"Property '{property}' is read-only");
var v = ToDouble(value);
if (v is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(value), "setpoint must be 0–30 V");
lock (_lock) _setpoint = v;
return Task.CompletedTask;
}
public Task<object?> ExecuteAsync(string action, object? args)
{
switch (action)
{
case "reset":
lock (_lock) _setpoint = 0;
return Task.FromResult<object?>("OK");
case "ramp":
var target = ArgDouble(args, "target", _setpoint);
// ... ramp at ArgDouble(args, "rate", 1.0) V/s ...
lock (_lock) _setpoint = target;
return Task.FromResult<object?>(target);
default:
throw new ArgumentException($"Unknown action: {action}");
}
}
}Rules for C# drivers
- Implement
IConnectorDriver(there is no base class). Required members:Name,InitAsync,ShutdownAsync,GetProperties,GetActions,GetAsync,SetAsync,ExecuteAsync, and theStreamEmitterproperty. Optional:Version,Description,SupportsStreaming,GetStreams. - Descriptors are records:
PropertyDescriptor(Name, Type, Access, Unit = "", Description = "", Details = ""),ActionDescriptor(Name, Description = "", Args = null, Details = ""),ArgDescriptor(Name, Type, Description = ""). Named arguments use the capitalised parameter names (Description:,Details:). [assembly: DriverGroup(...)]is an assembly attribute: put it above thenamespaceline, not on the class.- Read configuration and arguments with the
DriverConfighelpers (GetString,GetInt,GetDouble,GetBool;ArgString,ArgInt,ArgDouble,ArgBool,ArgDoubleArray;ToDouble,ToInt,ToBool). - Calls are not serialised. Polling, scripts and the UI call into the driver concurrently — guard shared state and the connection.
- Streams: return
truefromSupportsStreaming, list names inGetStreams(), and callStreamEmitter?.Invoke("data", json)with a JSON string. The emitter is only set whenSupportsStreamingis true. - Native libraries the vendor SDK needs go next to your DLL in the package root.
Installing a driver
Loose file (JavaScript and Python). Put mydevice.driver.js or mydevice.driver.py directly in workspace/drivers/ — the top level, not a sub-folder. No manifest is needed. Muxit reads drivers only when the server starts, so the user restarts Muxit, then creates a connector:
// workspace/connectors/bench_device.js
export default {
driver: "MyDevice",
config: { port: "/dev/ttyUSB0", baudRate: 115200 },
};Package (.muxdriver, required for C#). A ZIP file with these files at its root — no folder inside:
| Type | Package contents |
|---|---|
| JavaScript | manifest.json, <name>.driver.js, template.js, optional README.md, CHANGELOG.md |
| Python | manifest.json, <name>.driver.py, template.js, other .py files it imports, requirements.txt |
| C# | manifest.json, template.js, <AssemblyName>.dll, its dependency DLLs and native libraries — not Muxit.Driver.Sdk.dll |
Name it after the id and version (yourname-mydevice-v1.0.0.muxdriver) and put it directly in workspace/drivers/ (C# and JavaScript packages can also be installed from Muxit's Extensions panel; Python packages only by copying the file). Restart the server.
manifest.json
{
"formatVersion": 1,
"id": "yourname/mydevice",
"name": "MyDevice",
"version": "1.0.0",
"description": "Line-based serial instrument driver with setpoint control and a line stream.",
"author": { "name": "Your Name" },
"tier": 1,
"category": "free",
"group": "instruments",
"entryPoint": "mydevice.driver.js",
"tags": ["serial"],
"license": "MIT"
}| Field | Rule |
|---|---|
formatVersion | 1. |
id | publisher/name (the publisher/ part is optional): lowercase letters, digits and -, starting with a letter or digit; publisher 2–40 characters, name 3–40. |
name | The driver name connectors use; must match the code's name. |
version | x.y.z, no suffix. Match the code's version. |
tier | 1 JavaScript, 2 Python, 3 C#. |
category | "free". (Premium drivers are signed by Muxit.) |
entryPoint | The file name only: mydevice.driver.js, mydevice.driver.py, or <AssemblyName>.dll. |
group | instruments, motion, communication or utilities. |
description | At least 40 characters. |
author | If present, must have name. |
tags | 1–8 of: spectroscopy, motion, camera, robotics, power, daq, io, serial, networking, cnc, plotter, laser, monitor, terminal, arduino, esp32, gcode, grbl, drawing, canvas, pen, axidraw, audio, synth, notification, midi, printer, label, barcode, other. |
template.js is the starter connector Muxit offers when the user adds a connector for your driver — required in a package. It is an ordinary connector file:
// template.js
export default {
driver: "MyDevice",
config: {
port: "/dev/ttyUSB0", // "COM3" on Windows
baudRate: 115200,
},
ai: { instructions: "MyDevice bench instrument. Setpoint range 0–30 V." },
};Add a CHANGELOG.md with a ## 1.0.0 heading per version if the driver will be shared.
Common mistakes
| Mistake | Instead |
|---|---|
access: "rw" or "r" | "R/W", "R", "W" exactly. |
Slow I/O in get | Cache values; update them in execute, from onData, or a background loop. get runs every 200 ms per readable property. |
| Opening the port in the constructor or at module level | In init(config). |
JavaScript: arrow functions for init / get / … | Methods, so this works. |
JavaScript: await transport.query(cmd) | Match replies through onData. |
JavaScript: delay(ms), Buffer.from, require("serialport") | new Promise((r) => setTimeout(r, ms)), strings, the built-in transports. |
JavaScript: __emitStream("data", { v: 1 }) | __emitStream("data", JSON.stringify({ v: 1 })) |
Python: print(...) | self.log(...) or stderr. |
Python: "args": {"count": "int"} | "args": {"count": {"type": "int", "description": "…"}} |
Python: import numpy at the top of the file | Inside init. |
C#: [DriverGroup(...)] on the class | [assembly: DriverGroup(...)] above the namespace. |
C#: no StreamEmitter property | Declare it even without streams. |
C#: shipping Muxit.Driver.Sdk.dll | Reference it with <Private>false</Private>; leave it out. |
requiresSafetyGates: false | Not available to third-party drivers; leave it out. |
| A driver for a device a protocol already handles | A connector with Scpi, LineText or BinaryStream. |
| Expecting the server to pick up a new driver file | Restart Muxit. |
Checklist
- [ ] A built-in protocol really cannot do this, and the language fits the device's SDK.
- [ ] The driver name is unique, not a built-in name, and identical in code, manifest and
template.js. - [ ] Every property has
type,access("R","W"or"R/W") and a one-linedescription; actions describe their arguments; enums, units and side effects are indetails. - [ ]
getreturns quickly from cached state; the connection opens ininit, closes inshutdown, andshutdownis safe wheninitnever ran. - [ ]
initvalidates required configuration and throws a message that names the option. - [ ] Errors are thrown with actionable messages; nothing fails silently.
- [ ] Every device command comes from the manual or the user; unverified ones are listed.
- [ ] JavaScript:
export defaultwithmeta, methods not arrows, replies viaonData, norequire(/import(text, stream data as strings. - [ ] Python:
METAaction args are dicts, third-party imports insideinit, noprint,run(...)at the bottom,requirements.txtfor dependencies. - [ ] C#: implements
IConnectorDriverwithStreamEmitter, assembly-levelDriverGroup, thread-safe, SDK referenced but not shipped. - [ ] Told the user where the file goes, to restart Muxit, and gave them a connector that uses the driver.