Un simulateur pour du vrai matériel. Ce projet fournit un serveur capable de générer plusieurs appareils simulés et de répondre aux demandes simultanément.
Ce projet fournit uniquement l'infrastructure requise pour lancer un serveur à partir d'un fichier de configuration (YAML, TOML ou json) et un moyen d'enregistrer des plugins de périphériques tiers via le mécanisme de point d'entrée Python.
Jusqu'à présent, le projet fournit des transports pour TCP, UDP et ligne série. La prise en charge de nouveaux transports (ex : USB, GPIB ou SPI) est implémentée en fonction des besoins.
Les PR sont les bienvenus !
( TL;DR : pip install sinstruments[all]
)
Depuis votre environnement Python préféré :
$ pip install sinstruments
De plus, si vous souhaitez écrire des fichiers de configuration YAML en YAML :
$ pip install sinstruments[yaml]
...ou, pour une configuration basée sur TOML :
$ pip install sinstruments[toml]
Une fois installé, le serveur peut être exécuté avec :
$ sinstruments-server -c <config file name>
Le fichier de configuration décrit les appareils que le serveur doit instancier ainsi qu'une série d'options telles que le(s) transport(s) sur lesquels chaque appareil écoute les requêtes.
Imaginez que vous deviez simuler 2 GE Pace 5000 accessibles via un port TCP chacun et un CryoCon 24C accessible via la ligne série.
Tout d'abord, assurez-vous que les dépendances sont installées avec :
$ pip install gepace[simulator] cryoncon[simulator]
Nous pouvons maintenant préparer une configuration YAML appelée simulator.yml
:
devices :
- class : Pace
name : pace-1
transports :
- type : tcp
url : :5000
- class : Pace
name : pace-2
transports :
- type : tcp
url : :5001
- class : CryoCon
name : cryocon-1
transports :
- type : serial
url : /tmp/cryocon-1
Nous sommes maintenant prêts à lancer le serveur :
$ sinstruments-server -c simulator.yml
C'est ça! Vous devriez maintenant pouvoir vous connecter à n'importe lequel des appareils Pace via TCP ou CryoCon en utilisant la ligne série émulée locale.
Essayons de nous connecter au premier Pace avec l'outil de ligne de commande Linux nc (alias netcat) et demandons le bien connu *IDN?
Commande SCPI :
$ nc localhost 5000
*IDN?
GE,Pace5000,204683,1.01A
Ceci est un résumé des bibliothèques d'instruments tierces connues qui fournissent leurs propres simulateurs.
Si vous avez écrit un appareil accessible au public, n'hésitez pas à compléter la liste ci-dessus en créant un PR.
Astuce : sinstruments-server ls
affiche une liste des plugins disponibles.
Le fichier de configuration peut être un fichier YAML, TOML ou JSON à condition qu'il se traduise en un dictionnaire avec la description donnée ci-dessous.
Dans ce chapitre, nous utiliserons YAML comme exemple de référence.
Le fichier doit contenir au moins une clé de niveau supérieur appelée devices
. La valeur doit être une liste de descriptions de périphériques :
devices :
- class : Pace
name : pace-1
transports :
- type : tcp
url : :5000
Chaque description d'appareil doit contenir :
tcp
, udp
, serial
sont pris en charge)Toutes les autres options données à chaque périphérique sont transmises directement à l'objet plugin spécifique au moment de l'exécution. Chaque plugin doit décrire les options supplémentaires qu'il prend en charge et comment les utiliser.
Pour les transports TCP et UDP, l' URL a le format <host>:<port>
.
Un hôte vide (comme dans l'exemple ci-dessus) est interprété comme 0.0.0.0
(ce qui signifie écouter sur toutes les interfaces réseau). Si l'hôte est 127.0.0.1
ou localhost
le périphérique ne sera accessible qu'à partir de la machine sur laquelle le simulateur est exécuté.
Une valeur de port de 0 signifie demander au système d'exploitation d'attribuer un port libre (utile pour exécuter une suite de tests). Sinon, il doit s'agir d'un port TCP ou UDP valide.
L' URL représente un fichier spécial créé par le simulateur pour simuler une ligne série accessible comme un fichier de ligne série Linux /dev/ttyS0
.
Cette fonctionnalité n'est disponible que sous Linux et les systèmes pour lesquels le pseudo terminal pty
est implémenté en python.
L' URL est facultative. Le simulateur créera toujours un nom non déterministe comme /dev/pts/4
et enregistrera ces informations au cas où vous auriez besoin d'y accéder. Cette fonctionnalité est particulièrement utile lors de l’exécution d’une suite de tests.
Vous êtes libre de choisir le chemin URL de votre choix (ex : /dev/ttyRP10
) tant que vous êtes sûr que le simulateur dispose des autorisations nécessaires pour créer le fichier symbolique.
Pour n'importe lequel des transports (TCP, UDP et ligne série), il est possible d'effectuer une simulation de base de la vitesse du canal de communication en fournissant un paramètre baudrate
supplémentaire à la configuration. Exemple:
- class : CryoCon
name : cryocon-1
transports :
- type : serial
url : /tmp/cryocon-1
baudrate : 9600
Le simulateur fournit une console python de porte dérobée gevent que vous pouvez activer si vous souhaitez accéder à distance à un processus de simulateur en cours d'exécution. Pour activer cette fonctionnalité, ajoutez simplement au niveau supérieur de la configuration ce qui suit :
backdoor : ["localhost": 10001]
devices :
- ...
Vous êtes libre de choisir n’importe quel autre port TCP et adresse de liaison. Sachez que cette porte dérobée ne fournit aucune authentification et ne tente en aucun cas de limiter ce que les utilisateurs distants peuvent faire. Toute personne pouvant accéder au serveur peut effectuer toutes les actions possibles par le processus Python en cours d'exécution. Ainsi, même si vous pouvez vous lier à n'importe quelle interface, pour des raisons de sécurité, il est recommandé de vous lier à une interface uniquement accessible à la machine locale, par exemple 127.0.0.1/localhost.
Usage
Une fois la porte dérobée configurée et le serveur exécuté, dans un autre terminal, connectez-vous avec :
$ nc 127.0.0.1 10001
Welcome to Simulator server console.
You can access me through the 'server()' function. Have fun!
>>> print(server())
...
Écrire un nouvel appareil est simple. Imaginons que vous souhaitiez simuler un oscilloscope SCPI. La seule chose que vous devez faire est d'écrire une classe héritant de BaseDevice et d'implémenter le handle_message(self, message)
où vous devez gérer les différentes commandes prises en charge par votre appareil :
# myproject/simulator.py
from sinstruments . simulator import BaseDevice
class Oscilloscope ( BaseDevice ):
def handle_message ( self , message ):
self . _log . info ( "received request %r" , message )
message = message . strip (). decode ()
if message == "*IDN?" :
return b"ACME Inc,O-3000,23l032,3.5A"
elif message == "*RST" :
self . _log . info ( "Resetting myself!" )
...
N'oubliez pas de toujours renvoyer bytes
! Le simulateur ne fait aucune supposition sur la façon d'encoder str
En supposant que ce fichier simulator.py
fait partie d'un package python appelé myproject
, la deuxième chose à faire est d'enregistrer votre plugin de simulation dans votre setup.py :
setup (
...
entry_points = {
"sinstruments.device" : [
"Oscilloscope=myproject.simulator:Oscilloscope"
]
}
)
Vous devriez maintenant pouvoir lancer votre simulateur en écrivant un fichier de configuration :
# oscilo.yml
devices :
- class : Oscilloscope
name : oscilo-1
transports :
- type : tcp
url : :5000
Lancez maintenant le serveur avec
$ sinstruments-server -c oscillo.yml
et vous devriez pouvoir vous connecter avec :
$ nc localhost 5000
*IDN?
ACME Inc,O-3000,23l032,3.5A
Par défaut, l' eol
est défini sur n
. Vous pouvez le changer en n'importe quel caractère avec :
class Oscilloscope ( BaseDevice ):
newline = b" r "
Si votre appareil implémente un protocole qui répond avec plusieurs réponses (potentiellement retardées) à une seule requête, vous pouvez le prendre en charge en convertissant handle_message()
en générateur :
class Oscilloscope ( BaseDevice ):
def handle_message ( self , message ):
self . _log . info ( "received request %r" , message )
message = message . strip (). decode ()
if message == "*IDN?" :
yield b"ACME Inc,O-3000,23l032,3.5A"
elif message == "*RST" :
self . _log . info ( "Resetting myself!" )
elif message == "GIVE:ME 10" :
for i in range ( 1 , 11 ):
yield f"Here's { i } n " . encode ()
...
N'oubliez pas de toujours produire bytes
! Le simulateur ne fait aucune supposition sur la façon d'encoder str
Si votre appareil simulé nécessite une configuration supplémentaire, il peut être fourni via le même fichier YAML.
Disons que vous souhaitez pouvoir configurer si votre appareil est en mode CONTROL
au démarrage. De plus, si aucune valeur initiale n'est configurée, elle doit être par défaut « OFF ».
Ajoutons d’abord ceci à notre exemple de configuration :
# oscilo.yml
devices :
- class : Oscilloscope
name : oscilo-1
control : ON
transports :
- type : tcp
url : :5000
Ensuite, nous ré-implémentons notre Oscilloscope __init__()
pour intercepter ce nouveau paramètre et nous le gérons dans handle_message()
:
class Oscilloscope ( BaseDevice ):
def __init__ ( self , name , ** opts ):
self . _control = opts . pop ( "control" , "OFF" ). upper ()
super (). __init__ ( name , ** opts )
def handle_message ( self , message ):
...
elif message == "CONTROL" :
return f"CONTROL { self . _control } n " . encode ()
...
Vous êtes libre d'ajouter autant d'options que vous le souhaitez tant qu'elles n'entrent pas en conflit avec les clés réservées name
, class
et transports
.
Certains instruments implémentent des protocoles qui ne sont pas gérés de manière appropriée par un protocole de message basé sur EOL.
Le simulateur vous permet d'écrire votre propre protocole de message. Voici un exemple :
from sinstruments . simulator import MessageProtocol
class FixSizeProtocol ( MessageProtocol ):
Size = 32
def read_messages ( self ):
transport = self . transport
buff = b''
while True :
buff += transport . read ( self . channel , size = 4096 )
if not buff :
return
for i in range ( 0 , len ( buff ), self . Size ):
message = buff [ i : i + self . Size ]
if len ( message ) < self . Size :
buff = message
break
yield message
class Oscilloscope ( BaseDevice ):
protocol = FixSizeProtocol
...
Si vous développez une bibliothèque Python qui donne accès à un instrument accessible via un socket ou une ligne série et que vous avez écrit un simulateur pour celle-ci, vous pourriez être intéressé à tester votre bibliothèque par rapport au simulateur.
sinstruments fournit une paire d'assistants pytest qui génèrent un simulateur dans un thread séparé.
server_context
La première utilisation consiste simplement à utiliser l'assistant server_context
. Il n'y a en fait rien de spécifique à Pytest dans cet assistant, vous pouvez donc imaginer l'utiliser également dans d'autres scénarios.
Voici un exemple :
import pytest
from sinstruments . pytest import server_context
cfg = {
"devices" : [{
"name" : "oscillo-1" ,
"class" : "Oscilloscope" ,
"transports" : [
{ "type" : "tcp" , "url" : "localhost:0" }
]
}]
}
def test_oscilloscope_id ():
with server_context ( cfg ) as server :
# put here code to perform your tests that need to communicate with
# the simulator. In this example an oscilloscope client
addr = server . devices [ "oscillo-1" ]. transports [ 0 ]. address
oscillo = Oscilloscope ( addr )
assert oscillo . idn (). startswith ( "ACME Inc,O-3000" )
Vous remarquerez peut-être que dans la configuration, nous utilisons le port 0
. Cela indique au simulateur d'écouter sur n'importe quel port libre fourni par le système d'exploitation.
Le test réel récupère l'adresse actuelle attribuée par le système d'exploitation et l'utilise dans le test.
Comme vous pouvez le constater, les tests ne dépendent pas de la disponibilité d'un port spécifique, ce qui les rend portables.
Voici une suggestion sur la façon dont vous pourriez écrire votre propre appareil en utilisant l'assistant server_context
. L'objectif était de réduire la quantité de code passe-partout dont vous avez besoin pour rédiger votre test :
@ pytest . fixture
def oscillo_server ():
with server_context ( config ) as server :
server . oscillo1 = server . devices [ "oscillo-1" ]
server . oscillo1 . addr = server . oscillo1 . transports [ 0 ]. address
yield server
def test_oscilloscope_current ( oscillo_server ):
oscillo = Oscilloscope ( oscillo_server . oscillo1 . addr )
assert .05 < oscillo . current () < 0.01
server
Une deuxième aide est le server
. Ce luminaire dépend d'une fonctionnalité config
existante qui doit être présente dans votre module. Voici un exemple suivant le code précédent :
from sinstruments . pytest import server
@ pytest . fixture
def config ()
yield cfg
def test_oscilloscope_voltage ( server ):
addr = server . devices [ "oscillo-1" ]. transports [ 0 ]. address
oscillo = Oscilloscope ( addr )
assert 5 < oscillo . voltage () < 10