How to Add an Instrument#

This tutorial walks through adding a new serial instrument to QInstrument. The reference implementation is QDS345; reading its source alongside this page is recommended.

The example instrument is a fictional “Acme Systems Model 1000” bench supply with two float properties — voltage (read/write) and current (read-only) — and a single reset method. Its serial protocol follows the standard CMD? / CMDvalue convention.


Step 1 — Directory structure#

Instruments live under instruments/<Manufacturer>/<Name>/. Create the directory tree and its __init__.py files:

instruments/
└── AcmeSystems/
    ├── __init__.py           ← empty
    └── Model1000/
        ├── __init__.py
        ├── instrument.py
        ├── fake.py
        ├── widget.py
        ├── tree.py
        └── Model1000Widget.ui

If the manufacturer directory does not yet exist, add it to the packages list in pyproject.toml (see Step 7).


Step 2 — instrument.py#

Inherit from QSerialInstrument. Define a comm dict with the serial parameters, call _registerProperties() from __init__, and implement identify().

from QInstrument.lib.QSerialInstrument import QSerialInstrument


class QModel1000(QSerialInstrument):
    '''Acme Systems Model 1000 bench supply.

    Properties
    ==========

    Control
    -------
    voltage : float [V]
        Output voltage setpoint.
        Range: 0 – 30.

    Status (read-only)
    ------------------
    current : float [A]
        Measured output current.
    '''

    comm = dict(baudRate=QSerialInstrument.BaudRate.Baud9600,
                dataBits=QSerialInstrument.DataBits.Data8,
                stopBits=QSerialInstrument.StopBits.OneStop,
                parity=QSerialInstrument.Parity.NoParity,
                flowControl=QSerialInstrument.FlowControl.NoFlowControl,
                eol='\n')

    def _registerProperties(self) -> None:
        self._register('voltage', 'VOLT')
        self.registerProperty('current', ptype=float, setter=None,
                              getter=lambda: self.getValue('IOUT?', float))

    def _registerMethods(self) -> None:
        self.registerMethod('reset', self.reset)

    def _register(self, name: str, cmd: str, dtype: type = float) -> None:
        '''Register a standard CMD? / CMDvalue property.'''
        def getter(): return self.getValue(f'{cmd}?', dtype)
        def setter(v): return self.transmit(f'{cmd}{dtype(v)}')
        self.registerProperty(name, getter=getter, setter=setter, ptype=dtype)

    def identify(self) -> bool:
        '''Return True if the device identifies as a Model 1000.

        Queries ``*IDN?`` and checks for ``'MODEL1000'`` in the response.
        '''
        return 'MODEL1000' in self.handshake('*IDN?')

    def reset(self) -> None:
        '''Reset the instrument to factory defaults.'''
        self.transmit('*RST')


if __name__ == '__main__':
    QModel1000.example()

__all__ = ['QModel1000']

Key points

  • comm uses long-form enum access (BaudRate.Baud9600 etc.) via QSerialInstrument class attributes — never the short form, which fails with PyQt6.

  • The _register() helper is the standard pattern for CMD? / CMDvalue properties. Copy it verbatim; only the details differ from instrument to instrument.

  • Non-standard properties (current above, which queries IOUT? but cannot be set) use registerProperty() directly with setter=None.

  • identify() must return True only for the correct model. find() calls it on each port until one succeeds.


Step 3 — fake.py#

Inherit from both QFakeInstrument and the instrument class (MRO order matters: fake first). Call the real _registerProperties() so the fake mirrors every registered property. QFakeInstrument provides a _store dict whose values are returned by the auto-generated getters, so most properties need no extra code.

from QInstrument.lib.QFakeInstrument import QFakeInstrument
from QInstrument.instruments.AcmeSystems.Model1000.instrument import QModel1000


class QFakeModel1000(QFakeInstrument, QModel1000):
    '''Simulated Model 1000 for UI development without hardware.

    ``voltage`` and ``current`` are backed by ``_store``.
    ``current`` is read-only in the real instrument; the fake
    initializes it to a plausible default so the widget renders
    sensibly at startup.
    '''

    def _registerProperties(self) -> None:
        QModel1000._registerProperties(self)
        self._store.setdefault('current', 0.0)


__all__ = ['QFakeModel1000']

Key points

  • QFakeInstrument._registerProperties() does not need to be called explicitly; QModel1000._registerProperties(self) runs first and the _register() helper produces closures that call getValue() / transmit(), which are no-ops in QFakeInstrument. The standard backing-attribute convention (self._AUTO) handles the rest automatically.

  • Clamp the _store values for read-only status properties to sensible defaults so the widget has something to display.

  • If any property uses non-standard getter/setter logic (like DS345’s amplitude or mute), override it in _registerProperties to use _store directly instead of calling the wire protocol.


Step 4 — widget.py#

Inherit from QInstrumentWidget. Set UIFILE to the .ui filename and INSTRUMENT to the instrument class. QInstrumentWidget.__init__ does the rest.

from QInstrument.lib.QInstrumentWidget import QInstrumentWidget
from QInstrument.instruments.AcmeSystems.Model1000.instrument import QModel1000


class QModel1000Widget(QInstrumentWidget):
    '''Control widget for the Acme Systems Model 1000 bench supply.'''

    UIFILE = 'Model1000Widget.ui'
    INSTRUMENT = QModel1000


if __name__ == '__main__':
    QModel1000Widget.example()

__all__ = ['QModel1000Widget']

The .ui file path is resolved relative to the subclass’s source file, so UIFILE needs only the bare filename.

Designing the ``.ui`` file

Open Qt Designer and create a QWidget form. Name each control widget to match the property name it should bind to:

  • A QDoubleSpinBox named voltage binds to the voltage property automatically.

  • A QLabel named current displays the current value (read-only binding; the widget is never edited by the user).

The binding is purely by name — no code is required. QInstrumentWidget calls device.get and device.set on every matching widget.

Registering ``minimum`` and ``maximum``

Pass minimum and maximum to registerProperty() (or _register()), and QInstrumentWidget will apply them to QDoubleSpinBox and QSpinBox widgets automatically:

self.registerProperty('voltage', getter=..., setter=...,
                      ptype=float, minimum=0., maximum=30.)

Using the fake device interactively

example() falls back to the fake automatically. When no hardware is found it imports the sibling fake module and instantiates the first class listed in its __all__. No extra class attribute is needed; placing a fake.py alongside widget.py is sufficient.


Step 5 — tree.py#

The parameter tree requires only two lines:

from QInstrument.lib.QInstrumentTree import QInstrumentTree
from QInstrument.instruments.AcmeSystems.Model1000.instrument import QModel1000


class QModel1000Tree(QInstrumentTree):
    '''Parameter tree for the Acme Systems Model 1000 bench supply.'''

    INSTRUMENT = QModel1000


if __name__ == '__main__':
    QModel1000Tree.example()

__all__ = ['QModel1000Tree']

The tree discovers all registered properties and methods at runtime. No .ui file or layout code is needed. pyqtgraph must be installed (pip install 'QInstrument[tree]').


Step 6 — __init__.py#

Use the make_getattr factory from lib.lazy so the package is importable without triggering Qt:

from QInstrument.lib.lazy import make_getattr

_lazy = {
    'QModel1000':       'instrument',
    'QFakeModel1000':   'fake',
    'QModel1000Widget': 'widget',
}

__getattr__ = make_getattr(_lazy, __name__)
__all__ = list(_lazy)

Step 7 — pyproject.toml#

Add both the manufacturer and model packages to the packages list:

[tool.setuptools]
packages = [
    ...existing entries...,
    "QInstrument.instruments.AcmeSystems",
    "QInstrument.instruments.AcmeSystems.Model1000",
]

Re-install the package in editable mode to pick up the changes:

pip install -e ".[dev]"

Step 8 — Test it#

Run the widget directly:

python -m QInstrument.instruments.AcmeSystems.Model1000.widget

With no hardware connected the widget opens in a disconnected state. Pass a fake device explicitly for a fully interactive test:

from qtpy.QtWidgets import QApplication
from QInstrument.instruments.AcmeSystems.Model1000 import (
    QModel1000Widget, QFakeModel1000)

app = QApplication([])
widget = QModel1000Widget(device=QFakeModel1000())
widget.show()
app.exec()

Add the instrument to the rack:

qinstrument Model1000
qinstrument --fake Model1000   # use the fake device

Verify auto-discovery:

from QInstrument.QInstrumentRack import QInstrumentRack
print(QInstrumentRack.availableInstruments())
# 'Model1000' should appear in the list

Instruments that share a protocol#

When several models differ only in hardware constants (power limits, wavelength, number of channels, etc.), define the shared logic in a base package and put the model-specific constants as class attributes on thin subclasses in sibling packages. See instruments/Novanta/Opus* for a worked example:

instruments/Novanta/
├── Opus/           ← base package (no widget.py — not shown in picker)
│   ├── instrument.py   QOpus base class
│   ├── fake.py
│   └── widget.py
├── Opus532/        ← model subclass
│   ├── instrument.py   class QOpus532(QOpus): WAVELENGTH = 532; ...
│   ├── fake.py
│   ├── widget.py
│   └── tree.py
├── Opus660/
└── Opus1064/

The base package may omit widget.py so it does not appear in the “Add instrument…” picker; only the concrete model packages do.