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
commuses long-form enum access (BaudRate.Baud9600etc.) viaQSerialInstrumentclass attributes — never the short form, which fails with PyQt6.The
_register()helper is the standard pattern forCMD?/CMDvalueproperties. Copy it verbatim; only the details differ from instrument to instrument.Non-standard properties (
currentabove, which queriesIOUT?but cannot be set) useregisterProperty()directly withsetter=None.identify()must returnTrueonly 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 callgetValue()/transmit(), which are no-ops inQFakeInstrument. The standard backing-attribute convention (self._AUTO) handles the rest automatically.Clamp the
_storevalues 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
amplitudeormute), override it in_registerPropertiesto use_storedirectly 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
QDoubleSpinBoxnamedvoltagebinds to thevoltageproperty automatically.A
QLabelnamedcurrentdisplays 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