Package: sinstruments
Instrument SimulatorA simulator for real hardware. This project provides a server able to spawn
multiple simulated devices and serve requests concurrently.This project provides only the required infrastructure to launch a server from
a configuration file (YAML, TOML or json) and a means to register third-party device plugins through the python entry point mechanism.So far, the project provides transports for TCP, UDP and serial line.
Support for new transports (ex: USB, GPIB or SPI) is being implemented on a
need basis.PRs are welcome!Installation(TL;DR:pip install sinstruments[all])From within your favorite python environment:$ pip install sinstrumentsAdditionally, if you want to write YAML configuration files in YAML:$ pip install sinstruments[yaml]...or, for TOML based configuration:$ pip install sinstruments[toml]ExecutionOnce installed the server can be run with:$ sinstruments-server -c <config file name>The configuration file describes which devices the server should instantiate
along with a series of options like the transport(s) each device listens for
requests on.ExampleImagine you need to simulate 2GE Pace 5000reachable through a TCP port each and aCryoCon 24Caccessible through the serial line.First, make sure the dependencies are installed with:$ pip install gepace[simulator] cryoncon[simulator]Now we can prepare a YAML configuration calledsimulator.yml:devices:-class:Pacename:pace-1transports:-type:tcpurl::5000-class:Pacename:pace-2transports:-type:tcpurl::5001-class:CryoConname:cryocon-1transports:-type:serialurl:/tmp/cryocon-1We are now ready to launch the server:$ sinstruments-server -c simulator.ymlThat's it! You should now be able to connect to any of the Pace devices through
TCP or the CryoCon using the local emulated serial line.Let's try connecting to the first Pace with thenc(aka netcat) linux command
line tool and ask for the well known*IDN?SCPI command:$ nc localhost 5000
*IDN?
GE,Pace5000,204683,1.01ADevice catalogThis is a summary of the known third-party instrumentation libraries which
provide their own simulators.cryoconfast-spinnergepaceicepapjulabovacuubrandxia-pfcuMythen detector (from Dectris) - not publicly available yetIf you wrote a publicly available device feel free complete the above list by
creating a PR.Hint:sinstruments-server lsshows a list of available plugins.ConfigurationThe configuration file can be a YAML, TOML or JSON file as long as it translates to a dictionary with the description given below.In this chapter we will use YAML as a reference example.The file should contain at least a top-level key calleddevices.
The value needs to be a list of device descriptions:devices:-class:Pacename:pace-1transports:-type:tcpurl::5000Each device description must contain:class: Each third-party plugin should describe which text
identify itselfname: a unique name. Each device must be given a unique name at
your choicetransports: a list of transports from where the device is accessible.
Most devices provide only one transport.type: Each transport must define its type (supported aretcp,udp,serial)url: the url where the device is listening onAny other options given to each device are passed directly to the specific
plugin object at runtime. Each plugin should describe which additional options
it supports and how to use them.TCP and UDPFor TCP and UDP transports, theurlhas the<host>:<port>format.An empty host (like in the above example) is interpreted as0.0.0.0(which
means listen on all network interfaces). If host is127.0.0.1orlocalhostthe device will only be accessible from the machine where the simulator is
running.A port value of 0 means ask the OS to assign a free port (useful for running
a test suite). Otherwise must be a valid TCP or UDP port.Serial lineTheurlrepresents a special file which is created by the simulator to
simulate a serial line accessible like a/dev/ttyS0linux serial line file.This feature is only available in linux and systems for which the pseudo
terminalptyis implemented in python.Theurlis optional. The simulator will always create a non deterministic
name like/dev/pts/4and it will log this information in case you need to
access. This feature is most useful when running a test suite.You are free to choose anyurlpath you like (ex:/dev/ttyRP10) as long
as you are sure the simulator has permissions to create the symbolic file.Simulating communication delaysFor any of the transports (TCP, UDP and serial line) is is possible to do basic
simulation of the communication channel speed by providing an additionalbaudrateparameter to the configuration. Example:-class:CryoConname:cryocon-1transports:-type:serialurl:/tmp/cryocon-1baudrate:9600Back doorThe simulator provides a gevent back door python console which you can activate
if you want to access a running simulator process remotely. To activate this
feature simply add to the top-level of the configuration the following:backdoor:["localhost":10001]devices:-...You are free to choose any other TCP port and bind address. Be aware that this
backdoor provides no authentication and makes no attempt to limit what
remote users can do. Anyone that can access the server can take any action that
the running python process can. Thus, while you may bind to any interface, for
security purposes it is recommended that you bind to one only accessible to the
local machine, e.g., 127.0.0.1/localhost.UsageOnce the backdoor is configured and the server is running, in a another
terminal, connect with:$ nc 127.0.0.1 10001
Welcome to Simulator server console.
You can access me through the 'server()' function. Have fun!
>>> print(server())
...Develop a new simulatorWritting a new device is simple. Let's imagine you want to simulate a SCPI
oscilloscope. The only thing you need to do is write a class inheriting
from BaseDevice and implement thehandle_message(self, message)where you
should handle the different commands supported by your device:# myproject/simulator.pyfromsinstruments.simulatorimportBaseDeviceclassOscilloscope(BaseDevice):defhandle_message(self,message):self._log.info("received request%r",message)message=message.strip().decode()ifmessage=="*IDN?":returnb"ACME Inc,O-3000,23l032,3.5A"elifmessage=="*RST":self._log.info("Resetting myself!")...Don't forget to always returnbytes! The simulator doesn't make any guesses
on how to encodestrAssuming this filesimulator.pyis part of a python package calledmyproject,
the second thing to do is register your simulator plugin in your setup.py:setup(...entry_points={"sinstruments.device":["Oscilloscope=myproject.simulator:Oscilloscope"]})You should now be able to launch your simulator by writing a configuration
file:# oscilo.ymldevices:-class:Oscilloscopename:oscilo-1transports:-type:tcpurl::5000Now launch the server with$ sinstruments-server -c oscillo.ymland you should be able to connect with:$ nc localhost 5000
*IDN?
ACME Inc,O-3000,23l032,3.5AConfiguring message terminatorBy default theeolis set to\n. You can change it to any character with:classOscilloscope(BaseDevice):newline=b"\r"Request with multiple answersIf your device implements a protocol which answers with multiple (potentially
delayed) answers to a single request, you can support this by
converting thehandle_message()into a generator:classOscilloscope(BaseDevice):defhandle_message(self,message):self._log.info("received request%r",message)message=message.strip().decode()ifmessage=="*IDN?":yieldb"ACME Inc,O-3000,23l032,3.5A"elifmessage=="*RST":self._log.info("Resetting myself!")elifmessage=="GIVE:ME 10":foriinrange(1,11):yieldf"Here's{i}\n".encode()...Don't forget to always yieldbytes! The simulator doesn't make any guesses
on how to encodestrSupport for specific configuration optionsIf your simulated device requires additional configuration, it can be supplied
through the same YAML file.Let's say you want to be able to configure if your device is inCONTROLmode
at startup. Additionally, if no initial value is configured, it should default
to 'OFF'.First lets add this to our configuration example:# oscilo.ymldevices:-class:Oscilloscopename:oscilo-1control:ONtransports:-type:tcpurl::5000Then, we re-implement our Oscilloscope__init__()to intercept this new
parameter and we handle it inhandle_message():classOscilloscope(BaseDevice):def__init__(self,name,**opts):self._control=opts.pop("control","OFF").upper()super().__init__(name,**opts)defhandle_message(self,message):...elifmessage=="CONTROL":returnf"CONTROL{self._control}\n".encode()...You are free to add as many options as you want as long as they don't conflict
with the reserved keysname,classandtransports.Writing a specific message protocolSome instruments implement protocols that are not suitably managed by a EOL
based message protocol.The simulator allows you to write your own message protocol. Here is an example:fromsinstruments.simulatorimportMessageProtocolclassFixSizeProtocol(MessageProtocol):Size=32defread_messages(self):transport=self.transportbuff=b''whileTrue:buff+=transport.read(self.channel,size=4096)ifnotbuff:returnforiinrange(0,len(buff),self.Size):message=buff[i:i+self.Size]iflen(message)<self.Size:buff=messagebreakyieldmessageclassOscilloscope(BaseDevice):protocol=FixSizeProtocol...Pytest fixtureIf you are developing a python library that provides access to an instrument
accessible through socket or serial line and you wrote a simulator for it, you
might be interested in testing your library against the simulator.sinstruments provides a pair of pytest helpers that spawn a simulator in a
separate thread.server_contextThe first usage is simply using theserver_contexthelper.
There is actually nothing pytest speficic about this helper so you could
imagine using it in other scenarios as well.Here is an example:importpytestfromsinstruments.pytestimportserver_contextcfg={"devices":[{"name":"oscillo-1","class":"Oscilloscope","transports":[{"type":"tcp","url":"localhost:0"}]}]}deftest_oscilloscope_id():withserver_context(cfg)asserver:# put here code to perform your tests that need to communicate with# the simulator. In this example an oscilloscope clientaddr=server.devices["oscillo-1"].transports[0].addressoscillo=Oscilloscope(addr)assertoscillo.idn().startswith("ACME Inc,O-3000")You might notice that in the configuration we use port0. This is telling
the simulator to listen on any free port provided by the OS.The actual test retrieves the current address assigned by the OS and uses it in
the test.As you can see, the tests are not dependent of the availability of one specific
port which makes them portable.Here is a suggestion on how you could write your own fixture using theserver_contexthelper. The aim was to reduce the amount of boilerplate
code you need to write your test:@pytest.fixturedefoscillo_server():withserver_context(config)asserver:server.oscillo1=server.devices["oscillo-1"]server.oscillo1.addr=server.oscillo1.transports[0].addressyieldserverdeftest_oscilloscope_current(oscillo_server):oscillo=Oscilloscope(oscillo_server.oscillo1.addr)assert.05<oscillo.current()<0.01serverA second helper is theserverfixture. This fixture depends on an existingconfigfeature that must be present in your module. Here is an example
following the previous code:fromsinstruments.pytestimportserver@pytest.fixturedefconfig()yieldcfgdeftest_oscilloscope_voltage(server):addr=server.devices["oscillo-1"].transports[0].addressoscillo=Oscilloscope(addr)assert5<oscillo.voltage()<10