├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── docs.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── AUTHORS.rst ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── bors.toml ├── dev-requirements.txt ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _static │ └── logo-full.jpg │ ├── conf.py │ ├── definitions.rst │ └── index.rst ├── pyproject.toml └── pyvisa_sim ├── __init__.py ├── channels.py ├── common.py ├── component.py ├── default.yaml ├── devices.py ├── highlevel.py ├── parser.py ├── sessions ├── __init__.py ├── gpib.py ├── serial.py ├── session.py ├── tcpip.py └── usb.py └── testsuite ├── __init__.py ├── conftest.py ├── fixtures ├── __init__.py └── channels.yaml ├── test_all.py ├── test_channel.py ├── test_common.py ├── test_parser.py └── test_serial.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 2' 5 | push: 6 | branches: 7 | - main 8 | - staging 9 | - trying 10 | pull_request: 11 | branches: 12 | - main 13 | paths: 14 | - .github/workflows/ci.yml 15 | - "pyvisa_sim/**" 16 | - pyproject.toml 17 | - setup.py 18 | 19 | jobs: 20 | formatting: 21 | name: Check code formatting 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.12" 29 | - name: Install tools 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install -r dev-requirements.txt 33 | - name: Format 34 | run: | 35 | ruff format pyvisa_sim --check; 36 | - name: Lint 37 | if: always() 38 | run: | 39 | ruff check pyvisa_sim; 40 | - name: Mypy 41 | if: always() 42 | run: | 43 | mypy pyvisa_sim; 44 | tests: 45 | name: Unit tests 46 | runs-on: ${{ matrix.os }} 47 | needs: 48 | - formatting 49 | if: needs.formatting.result == 'success' 50 | strategy: 51 | matrix: 52 | os: [ubuntu-latest, windows-latest, macos-latest] 53 | python-version: ["3.10", "3.11", "3.12", "3.13"] 54 | steps: 55 | - uses: actions/checkout@v4 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v5 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | - name: Install dependencies 61 | run: | 62 | python -m pip install --upgrade pip 63 | - name: Install project 64 | run: | 65 | pip install -e . 66 | - name: Test with pytest 67 | run: | 68 | pip install pytest-cov 69 | pytest --pyargs pyvisa_sim --cov --cov-report xml -v 70 | - name: Upload coverage to Codecov 71 | uses: codecov/codecov-action@v5 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | flags: unittests 75 | name: codecov-umbrella 76 | fail_ci_if_error: true 77 | 78 | # Added to summarize the matrix (otherwise we would need to list every single 79 | # job in bors.toml) 80 | tests-result: 81 | name: Tests result 82 | if: always() 83 | needs: 84 | - tests 85 | runs-on: ubuntu-latest 86 | steps: 87 | - name: Mark the job as a success 88 | if: needs.tests.result == 'success' 89 | run: exit 0 90 | - name: Mark the job as a failure 91 | if: needs.tests.result != 'success' 92 | run: exit 1 93 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation building 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 2" 5 | push: 6 | branches: 7 | - main 8 | - staging 9 | - trying 10 | pull_request: 11 | branches: 12 | - master 13 | paths: 14 | - .github/workflows/docs.yml 15 | - pyvisa_sim/* 16 | - docs/* 17 | - setup.py 18 | 19 | jobs: 20 | docs: 21 | name: Docs building 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | - name: Install project 31 | run: | 32 | pip install -e . 33 | - name: Install doc building tools 34 | run: | 35 | pip install sphinx sphinx_rtd_theme 36 | - name: Build documentation 37 | run: | 38 | mkdir docs_output; 39 | sphinx-build docs/source docs_output -W -b html; 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload wheels 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * 3' 6 | push: 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build_sdist: 12 | name: Build sdist 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Get history and tags for SCM versioning to work 18 | run: | 19 | git fetch --prune --unshallow 20 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 21 | - name: Setup Python 22 | uses: actions/setup-python@v5 23 | - name: Build sdist 24 | run: | 25 | pip install --upgrade pip 26 | pip install wheel build 27 | python -m build . -s 28 | - name: Test sdist 29 | run: | 30 | pip install pytest 31 | pip install dist/*.tar.gz 32 | python -X dev -m pytest --pyargs pyvisa_sim 33 | - name: Store artifacts 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: cibw-sdist 37 | path: dist/* 38 | 39 | build_wheel: 40 | name: Build wheel 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Get history and tags for SCM versioning to work 46 | run: | 47 | git fetch --prune --unshallow 48 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 49 | - name: Setup Python 50 | uses: actions/setup-python@v5 51 | - name: Build wheels 52 | run: | 53 | pip install --upgrade pip 54 | pip install wheel build 55 | python -m build . -w 56 | - name: Test wheel 57 | run: | 58 | pip install pytest 59 | pip install dist/*.whl 60 | python -X dev -m pytest --pyargs pyvisa_sim 61 | - name: Store artifacts 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: cibw-wheels 65 | path: dist/*.whl 66 | 67 | publish: 68 | if: github.event_name == 'push' 69 | needs: [build_wheel, build_sdist] 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/download-artifact@v4 73 | with: 74 | pattern: cibw-* 75 | path: dist 76 | merge-multiple: true 77 | 78 | - uses: pypa/gh-action-pypi-publish@release/v1 79 | with: 80 | user: __token__ 81 | password: ${{ secrets.pypi_password }} 82 | # To test: 83 | # repository_url: https://test.pypi.org/legacy/ 84 | 85 | github-release: 86 | name: >- 87 | Sign the Python 🐍 distribution 📦 with Sigstore 88 | and create a GitHub Release 89 | runs-on: ubuntu-latest 90 | needs: 91 | - publish 92 | 93 | permissions: 94 | contents: write 95 | id-token: write 96 | 97 | steps: 98 | - name: Download all the dists 99 | uses: actions/download-artifact@v4 100 | with: 101 | name: artifact 102 | path: dist 103 | - name: Sign the dists with Sigstore 104 | uses: sigstore/gh-action-sigstore-python@v3.0.0 105 | with: 106 | password: ${{ secrets.pypi_password }} 107 | inputs: >- 108 | ./dist/*.tar.gz 109 | ./dist/*.whl 110 | - name: Create GitHub Release 111 | env: 112 | GITHUB_TOKEN: ${{ github.token }} 113 | run: >- 114 | gh release create 115 | '${{ github.ref_name }}' 116 | --repo '${{ github.repository }}' 117 | --generate-notes 118 | - name: Upload artifact signatures to GitHub Release 119 | env: 120 | GITHUB_TOKEN: ${{ github.token }} 121 | run: >- 122 | gh release upload 123 | '${{ github.ref_name }}' dist/** 124 | --repo '${{ github.repository }}' 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,pycharm+all 2 | # Edit at https://www.gitignore.io/?templates=python,pycharm+all 3 | 4 | ### PyCharm+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### PyCharm+all Patch ### 75 | # Ignores the whole .idea folder and all .iml files 76 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 77 | 78 | .idea/ 79 | 80 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 81 | 82 | *.iml 83 | modules.xml 84 | .idea/misc.xml 85 | *.ipr 86 | 87 | # Sonarlint plugin 88 | .idea/sonarlint 89 | 90 | ### Python ### 91 | # Byte-compiled / optimized / DLL files 92 | __pycache__/ 93 | *.py[cod] 94 | *$py.class 95 | 96 | # C extensions 97 | *.so 98 | 99 | # Distribution / packaging 100 | .Python 101 | build/ 102 | develop-eggs/ 103 | dist/ 104 | downloads/ 105 | eggs/ 106 | .eggs/ 107 | lib/ 108 | lib64/ 109 | parts/ 110 | sdist/ 111 | var/ 112 | wheels/ 113 | pip-wheel-metadata/ 114 | share/python-wheels/ 115 | *.egg-info/ 116 | .installed.cfg 117 | *.egg 118 | MANIFEST 119 | version.py 120 | 121 | # PyInstaller 122 | # Usually these files are written by a python script from a template 123 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 124 | *.manifest 125 | *.spec 126 | 127 | # Installer logs 128 | pip-log.txt 129 | pip-delete-this-directory.txt 130 | 131 | # Unit test / coverage reports 132 | htmlcov/ 133 | .tox/ 134 | .nox/ 135 | .coverage 136 | .coverage.* 137 | .cache 138 | nosetests.xml 139 | coverage.xml 140 | *.cover 141 | .hypothesis/ 142 | .pytest_cache/ 143 | 144 | # Translations 145 | *.mo 146 | *.pot 147 | 148 | # Scrapy stuff: 149 | .scrapy 150 | 151 | # Sphinx documentation 152 | docs/_build/ 153 | 154 | # PyBuilder 155 | target/ 156 | 157 | # pyenv 158 | .python-version 159 | 160 | # pipenv 161 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 162 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 163 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 164 | # install all needed dependencies. 165 | #Pipfile.lock 166 | 167 | # celery beat schedule file 168 | celerybeat-schedule 169 | 170 | # SageMath parsed files 171 | *.sage.py 172 | 173 | # Spyder project settings 174 | .spyderproject 175 | .spyproject 176 | 177 | # Rope project settings 178 | .ropeproject 179 | 180 | # Mr Developer 181 | .mr.developer.cfg 182 | .project 183 | .pydevproject 184 | 185 | # mkdocs documentation 186 | /site 187 | 188 | # mypy 189 | .mypy_cache/ 190 | .dmypy.json 191 | dmypy.json 192 | 193 | # Pyre type checker 194 | .pyre/ 195 | 196 | # End of https://www.gitignore.io/api/python,pycharm+all -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.11.12 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | # Run the formatter. 9 | - id: ruff-format 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | rev: v1.16.0 # Use the sha / tag you want to point at 12 | hooks: 13 | - id: mypy 14 | additional_dependencies: [pyvisa, types-PyYAML, stringparser, pytest] 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.10" 13 | 14 | # Build documentation in the docs/source directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # Enable epub output 19 | formats: 20 | - epub 21 | 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: docs/requirements.txt 26 | - method: pip 27 | path: . 28 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | pyvisa-sim was created by Hernan E. Grecco 2 | hernan.grecco@gmail.com. 3 | 4 | It is currently maintained by: 5 | - Matthieu Dartiailh m.dartiailh@gmail.com 6 | 7 | 8 | Other contributors, listed alphabetically, are: 9 | 10 | - Adam Vaughn avaughn@intersil.com 11 | - Colin Marquardt github@marquardt-home.de 12 | - Huan Nguyen famish99@gmail.com 13 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | PyVISA-sim Changelog 2 | ==================== 3 | 4 | 0.7.0 (01-04-2025) 5 | ------------------ 6 | 7 | - add support for Python 3.13 PR #124 8 | - drop support for Python 3.8 and 3.9 PR #124 9 | - add support for RANDOM directive PR #96 10 | RANDOM allows to return 1 or more random values between a min and a max value 11 | - fixed `SimVisaLibrary.read` violating the `viRead` specification in various ways. This 12 | fixed issue #45 and other bugs. PR #98 13 | 14 | 0.6.0 (2023-11-27) 15 | ------------------ 16 | 17 | - fixed debug logging a single character at a time. PR #79 18 | - fixed issue with `common.iter_bytes` where the masked bits would be incorrect. 19 | PR #81 20 | 21 | 0.5.1 (2022-09-08) 22 | ------------------ 23 | 24 | - fix rendering issues in the README 25 | 26 | 0.5 (2022-09-08) 27 | ---------------- 28 | 29 | - add support for secondary GPIB addresses 30 | - remove last uses of the six package and of ``__future__`` imports 31 | 32 | 0.4 (2020-10-26) 33 | ---------------- 34 | 35 | - Use SCM based version number PR #53 36 | - Work with PyVISA >= 1.11 PR #53 37 | - Drop support for Python 2, 3.4 and 3.5 PR #53 38 | - Drop support for Python 3.2 (EOL 2016-02-27) 39 | - Drop support for Python 3.3 (EOL 2017-09-29) 40 | - Add support for Python 3.7 and 3.8 41 | - Add tox for project setup and test automation 42 | - Switch from unittest to pytest 43 | 44 | .. _03-2015-08-25: 45 | 46 | 0.3 (2015-08-25) 47 | ---------------- 48 | 49 | - Fixed bug in get_device_dict. (Issue #37) 50 | - Move resource name parsing to pyvisa.rname. 51 | - Implemented query in list_resources. 52 | - Add support for USB RAW. 53 | - Warn the user when no eom is specified for device type and use LF. 54 | 55 | .. _02-2015-05-19: 56 | 57 | 0.2 (2015-05-19) 58 | ---------------- 59 | 60 | - Add support for channels. (Issue #9, thanks MatthieuDartiailh) 61 | - Add support for error queue. (Issue #26, thanks MatthieuDartiailh) 62 | - Add support for TCPIP SOCKET. (Issue #29, thanks MatthieuDartiailh) 63 | - Removed resource string parsing in favour of to pyvisa.rname. 64 | - Changed find_resource and find_next in favour of list_resources. 65 | - Implemented new loader with bases and versioning enforcing. (Issue 66 | #16) 67 | - Renamed is_resource to bundled in yaml files. 68 | - Added support for an empty response. (Issue #15, thanks famish99) 69 | - Several small fixes and better VISA compliance. 70 | - Better error reporting and debug info. 71 | 72 | .. _01-2015-02-12: 73 | 74 | 0.1 (2015-02-12) 75 | ---------------- 76 | 77 | - First public release. 78 | - Basic ASRL INSTR functionality. 79 | - Basic USB INSTR functionality. 80 | - Basic TCPIP INSTR functionality. 81 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 PyVISA-sim Authors and contributors. See AUTHORS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst *.txt *.toml 2 | 3 | recursive-include pyvisa-sim * 4 | recursive-include docs * 5 | 6 | prune docs/_build 7 | 8 | global-exclude *.pyc *~ .DS_Store *__pycache__* *.pyo 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyVISA-sim 2 | ========== 3 | 4 | .. image:: https://github.com/pyvisa/pyvisa-sim/workflows/Continuous%20Integration/badge.svg 5 | :target: https://github.com/pyvisa/pyvisa-sim/actions 6 | :alt: Continuous integration 7 | .. image:: https://github.com/pyvisa/pyvisa-sim/workflows/Documentation%20building/badge.svg 8 | :target: https://github.com/pyvisa/pyvisa/actions 9 | :alt: Documentation building 10 | .. image:: https://codecov.io/gh/pyvisa/pyvisa-sim/branch/main/graph/badge.svg 11 | :target: https://codecov.io/gh/pyvisa/pyvisa-sim 12 | :alt: Code Coverage 13 | .. image:: https://readthedocs.org/projects/pyvisa-sim/badge/?version=latest 14 | :target: https://pyvisa-sim.readthedocs.io/en/latest/?badge=latest 15 | :alt: Documentation Status 16 | .. image:: https://img.shields.io/pypi/l/PyVISA-sim 17 | :target: https://pypi.python.org/pypi/pyvisa-sim 18 | :alt: PyPI - License 19 | .. image:: https://img.shields.io/pypi/v/PyVISA-sim 20 | :target: https://pypi.python.org/pypi/pyvisa-sim 21 | :alt: PyPI 22 | 23 | PyVISA-sim is a PyVISA backend that simulates a large part of the 24 | "Virtual Instrument Software Architecture" (`VISA`_). 25 | 26 | Description 27 | ----------- 28 | 29 | PyVISA started as a wrapper for the NI-VISA library and therefore you 30 | need to install the National Instruments VISA library in your system. 31 | This works most of the time, for most people. But sometimes you need to 32 | test PyVISA without the physical devices or even without NI-VISA. 33 | 34 | Starting from version 1.6, PyVISA allows to use different backends. 35 | These backends can be dynamically loaded. PyVISA-sim is one of such 36 | backends. It implements most of the methods for Message Based 37 | communication (Serial/USB/GPIB/Ethernet) in a simulated environment. The 38 | behaviour of simulated devices can be controlled by a simple plain text 39 | configuration file. 40 | 41 | VISA and Python 42 | --------------- 43 | 44 | Python has a couple of features that make it very interesting for 45 | measurement controlling: 46 | 47 | - Python is an easy-to-learn scripting language with short development 48 | cycles. 49 | - It represents a high abstraction level, which perfectly blends with 50 | the abstraction level of measurement programs. 51 | - It has a very rich set of native libraries, including numerical and 52 | plotting modules for data analysis and visualisation. 53 | - A large set of books (in many languages) and on-line publications is 54 | available. 55 | 56 | Requirements 57 | ------------ 58 | 59 | - Python (tested with 3.8 to 3.11) 60 | - PyVISA 1.11+ 61 | 62 | Installation 63 | ------------ 64 | 65 | Using ``pip``: 66 | 67 | $ pip install -U pyvisa-sim 68 | 69 | or install the development version: 70 | 71 | $ pip install git+https://github.com/pyvisa/pyvisa-sim 72 | 73 | PyVISA is automatically installed if needed. 74 | 75 | 76 | Documentation 77 | ------------- 78 | 79 | The documentation can be read online at https://pyvisa-sim.readthedocs.org 80 | 81 | .. _VISA: http://www.ivifoundation.org/Downloads/Specifications.html 82 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = ["Check code formatting", "Tests result", "Docs building", "pyvisa.keysight-assisted"] 2 | delete-merged-branches = true 3 | timeout_sec = 600 -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pyvisa 2 | ruff 3 | mypy 4 | types-PyYAML 5 | pytest 6 | sphinx 7 | sphinx-rtd-theme -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pyvisa-sim.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyvisa-sim.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pyvisa-sim" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyvisa-sim" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pyvisa-sim.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pyvisa-sim.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=4 2 | sphinx-rtd-theme>=1 3 | -------------------------------------------------------------------------------- /docs/source/_static/logo-full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyvisa/pyvisa-sim/b7e7b3018a9dba17ee2139fc28f33f9b774fc70f/docs/source/_static/logo-full.jpg -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PyVISA-sim documentation build configuration file 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import datetime 14 | import importlib.metadata 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | # needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [ 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.doctest", 31 | "sphinx.ext.intersphinx", 32 | "sphinx.ext.coverage", 33 | "sphinx.ext.viewcode", 34 | "sphinx.ext.mathjax", 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = ".rst" 42 | 43 | # The encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = "index" 48 | 49 | # General information about the project. 50 | project = "PyVISA-sim" 51 | author = "PyVISA-sim Authors" 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | 56 | version = importlib.metadata.version(project) 57 | release = version 58 | this_year = datetime.date.today().year 59 | copyright = "%s, %s" % (this_year, author) 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | # today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | # today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = ["_build"] 74 | 75 | # The reST default role (used for this markup: `text`) to use for all documents. 76 | # default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | # add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | # add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | # show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = "sphinx" 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | # modindex_common_prefix = [] 94 | 95 | 96 | # -- Options for HTML output --------------------------------------------------- 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | html_theme = "sphinx_rtd_theme" 100 | 101 | # Theme options are theme-specific and customize the look and feel of a theme 102 | # further. For a list of options available for each theme, see the 103 | # documentation. 104 | # html_theme_options = {} 105 | 106 | # Add any paths that contain custom themes here, relative to this directory. 107 | # html_theme_path = [] 108 | # html_theme_path = ['_themes'] 109 | 110 | # The name for this set of Sphinx documents. If None, it defaults to 111 | # " v documentation". 112 | # html_title = None 113 | 114 | # A shorter title for the navigation bar. Default is the same as html_title. 115 | # html_short_title = None 116 | 117 | # The name of an image file (relative to this directory) to place at the top 118 | # of the sidebar. 119 | # html_logo = None 120 | 121 | # The name of an image file (within the static path) to use as favicon of the 122 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | # html_favicon = None 125 | 126 | # Add any paths that contain custom static files (such as style sheets) here, 127 | # relative to this directory. They are copied after the builtin static files, 128 | # so a file named "default.css" will overwrite the builtin "default.css". 129 | html_static_path = ["_static"] 130 | 131 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 132 | # using the given strftime format. 133 | # html_last_updated_fmt = '%b %d, %Y' 134 | 135 | # If true, SmartyPants will be used to convert quotes and dashes to 136 | # typographically correct entities. 137 | # html_use_smartypants = True 138 | 139 | # Custom sidebar templates, maps document names to template names. 140 | # html_sidebars = {} 141 | html_sidebars = { 142 | "index": ["sidebarintro.html", "sourcelink.html", "searchbox.html"], 143 | "**": [ 144 | "sidebarlogo.html", 145 | "localtoc.html", 146 | "relations.html", 147 | "sourcelink.html", 148 | "searchbox.html", 149 | ], 150 | } 151 | 152 | # Additional templates that should be rendered to pages, maps page names to 153 | # template names. 154 | # html_additional_pages = {} 155 | 156 | # If false, no module index is generated. 157 | # html_domain_indices = True 158 | 159 | # If false, no index is generated. 160 | # html_use_index = True 161 | 162 | # If true, the index is split into individual pages for each letter. 163 | # html_split_index = False 164 | 165 | # If true, links to the reST sources are added to the pages. 166 | # html_show_sourcelink = True 167 | 168 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 169 | # html_show_sphinx = True 170 | 171 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 172 | # html_show_copyright = True 173 | 174 | # If true, an OpenSearch description file will be output, and all pages will 175 | # contain a tag referring to it. The value of this option must be the 176 | # base URL from which the finished HTML is served. 177 | # html_use_opensearch = '' 178 | 179 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 180 | # html_file_suffix = None 181 | 182 | # Output file base name for HTML help builder. 183 | htmlhelp_basename = "pyvisa-simtdoc" 184 | 185 | 186 | # -- Options for LaTeX output -------------------------------------------------- 187 | 188 | # latex_elements = { 189 | # # The paper size ('letterpaper' or 'a4paper'). 190 | # #'papersize': 'letterpaper', 191 | # # The font size ('10pt', '11pt' or '12pt'). 192 | # #'pointsize': '10pt', 193 | # # Additional stuff for the LaTeX preamble. 194 | # #'preamble': '', 195 | # } 196 | 197 | # Grouping the document tree into LaTeX files. List of tuples 198 | # (source start file, target name, title, author, documentclass [howto/manual]). 199 | latex_documents = [ 200 | ("index", "pyvisa-sim.tex", "PyVISA Documentation", "PyVISA Authors", "manual"), 201 | ] 202 | 203 | # The name of an image file (relative to this directory) to place at the top of 204 | # the title page. 205 | # latex_logo = None 206 | 207 | # For "manual" documents, if this is true, then toplevel headings are parts, 208 | # not chapters. 209 | # latex_use_parts = False 210 | 211 | # If true, show page references after internal links. 212 | # latex_show_pagerefs = False 213 | 214 | # If true, show URL addresses after external links. 215 | # latex_show_urls = False 216 | 217 | # Documents to append as an appendix to all manuals. 218 | # latex_appendices = [] 219 | 220 | # If false, no module index is generated. 221 | # latex_domain_indices = True 222 | 223 | 224 | # -- Options for manual page output -------------------------------------------- 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [("index", "pyvisa-sim", "PyVISA Documentation", ["PyVISA Authors"], 1)] 229 | 230 | # If true, show URL addresses after external links. 231 | # man_show_urls = False 232 | 233 | 234 | # -- Options for Texinfo output ------------------------------------------------ 235 | 236 | # Grouping the document tree into Texinfo files. List of tuples 237 | # (source start file, target name, title, author, 238 | # dir menu entry, description, category) 239 | texinfo_documents = [ 240 | ( 241 | "index", 242 | "PyVISA", 243 | "PyVISA Documentation", 244 | "PyVISA Authors", 245 | "PyVISA", 246 | "One line description of project.", 247 | "Miscellaneous", 248 | ), 249 | ] 250 | 251 | # Documents to append as an appendix to all manuals. 252 | # texinfo_appendices = [] 253 | 254 | # If false, no module index is generated. 255 | # texinfo_domain_indices = True 256 | 257 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 258 | # texinfo_show_urls = 'footnote' 259 | 260 | 261 | # -- Options for Epub output --------------------------------------------------- 262 | 263 | # Bibliographic Dublin Core info. 264 | epub_title = project 265 | epub_author = author 266 | epub_publisher = author 267 | epub_copyright = copyright 268 | 269 | # The language of the text. It defaults to the language option 270 | # or en if the language is not set. 271 | # epub_language = '' 272 | 273 | # The scheme of the identifier. Typical schemes are ISBN or URL. 274 | # epub_scheme = '' 275 | 276 | # The unique identifier of the text. This can be a ISBN number 277 | # or the project homepage. 278 | # epub_identifier = '' 279 | 280 | # A unique identification for the text. 281 | # epub_uid = '' 282 | 283 | # A tuple containing the cover image and cover page html template filenames. 284 | # epub_cover = () 285 | 286 | # HTML files that should be inserted before the pages created by sphinx. 287 | # The format is a list of tuples containing the path and title. 288 | # epub_pre_files = [] 289 | 290 | # HTML files shat should be inserted after the pages created by sphinx. 291 | # The format is a list of tuples containing the path and title. 292 | # epub_post_files = [] 293 | 294 | # A list of files that should not be packed into the epub file. 295 | # epub_exclude_files = [] 296 | 297 | # The depth of the table of contents in toc.ncx. 298 | # epub_tocdepth = 3 299 | 300 | # Allow duplicate toc entries. 301 | # epub_tocdup = True 302 | 303 | 304 | # Example configuration for intersphinx: refer to the Python standard library. 305 | intersphinx_mapping = {"python": ("http://docs.python.org/3", None)} 306 | -------------------------------------------------------------------------------- /docs/source/definitions.rst: -------------------------------------------------------------------------------- 1 | .. _definitions: 2 | 3 | Building your own simulated instruments 4 | ======================================= 5 | 6 | PyVISA-sim provides some simulated instruments but the real cool thing is that 7 | it allows you to write your own in simple YAML_ files. 8 | 9 | Here we will go through the structure of such a file, using the `one provided 10 | with pyvisa-sim`_ as an example. The first line you will find is the 11 | specification version: 12 | 13 | .. code-block:: yaml 14 | 15 | spec: "1.1" 16 | 17 | This allow us to introduce changes to the specification without breaking user's 18 | code and definition files. Hopefully we will not need to do that, but we like 19 | to be prepared. So, do not worry about this but make sure you include it. 20 | 21 | The rest of the file can be divided in two sections: devices and resources. We 22 | will guide you through describing the `Lantz Example Driver`_ 23 | 24 | devices 25 | ------- 26 | 27 | It is a dictionary that defines each device, its dialogues and properties. The 28 | keys of this dictionary are the device names which must be unique within this 29 | file. For example: 30 | 31 | .. code-block:: yaml 32 | 33 | devices: 34 | HP33120A: 35 | 36 | SR830: 37 | 38 | 39 | The device definition is a dictionary with the following keys: 40 | 41 | 42 | eom 43 | ~~~ 44 | 45 | Specifies the end-of-message for each instrument type and resource class pair. 46 | For example: 47 | 48 | .. code-block:: yaml 49 | 50 | eom: 51 | ASRL INSTR: 52 | q: "\r\n" 53 | r: "\n" 54 | 55 | means that **ASRL INSTR** resource queries are expected to end in **\r\n** and 56 | responses end in **\n**. The **q**, **r** pair is a common structure that will 57 | repeat in dialogues, getters and setters. 58 | 59 | You can specify the eom for as many types as you like. The correct one will be 60 | selected when a device is assigned to a resource, as we will see later. 61 | 62 | 63 | error 64 | ~~~~~ 65 | 66 | The error key specifies the default message to be given when a message is not 67 | understood or the user tries to set a property outside the right range. 68 | For example: 69 | 70 | .. code-block:: yaml 71 | 72 | error: ERROR 73 | 74 | This means that the word **ERROR** is returned. 75 | 76 | If you want to further customize how your device handles errors, you can split 77 | the error types in two: **command_error** which is returned when fed an invalid 78 | command or an out of range command, or **query_error** which is returned when 79 | trying to read an empty buffer. 80 | 81 | .. code-block:: yaml 82 | 83 | error: 84 | response: 85 | command_error: null_response 86 | status_register: 87 | - q: "*ESR?" 88 | command_error: 32 89 | query_error: 4 90 | 91 | In addition to customizing how responses are generated you can specify a status 92 | register in which errors are tracked. Each element in the list specifies a 93 | single register so in the example above, if both a **command_error** and 94 | **query_error** are raised, then querying `'*ESR?'` will return `'36'`. 95 | 96 | 97 | dialogues 98 | ~~~~~~~~~ 99 | 100 | This is one of the main concepts of PyVISA-sim. A dialogue is a query which may 101 | be followed by a response. The dialogues item is a list of elements, normally 102 | **q**, **r** pairs. For example: 103 | 104 | .. code-block:: yaml 105 | 106 | dialogues: 107 | - q: "?IDN" 108 | r: "LSG Serial #1234" 109 | 110 | If the response (**r**) is not provided, no response will be given by the device. 111 | Conversely, if **null_response** is provided for response (**r**), then no 112 | response will be given by the device as well. 113 | 114 | You can have as many items as you want. 115 | 116 | 117 | properties 118 | ~~~~~~~~~~ 119 | 120 | This is the other important part of the device. Consider it as a dialogue with 121 | some memory. It is a dictionary. The key is the name of the property and the 122 | value is the property definition. 123 | For example: 124 | 125 | .. code-block:: yaml 126 | 127 | properties: 128 | frequency: 129 | default: 100.0 130 | getter: 131 | q: "?FREQ" 132 | r: "{:.2f}" 133 | setter: 134 | q: "!FREQ {:.2f}" 135 | r: OK 136 | specs: 137 | min: 1 138 | max: 100000 139 | type: float 140 | 141 | This says that there is a property called **frequency** with a default value of 142 | **100.0**. 143 | 144 | To get the current frequency value you need to send **?FREQ** and the response 145 | will be formatted as **{:.2f}**. This is the PEP3101_ formatting specification. 146 | 147 | To set the frequency value you need to send **!FREQ** followed by a number 148 | formatted as **{:.2f}**. Again this is the PEP3101_ formatting specification 149 | but used for parsing. 150 | 151 | If you want know more about it, take a look at the stringparser_ library. 152 | 153 | If setting the property was successful, the response will be **OK**. 154 | If there was an error, the response will be **ERROR** (the default). You can 155 | specify an error-specific error message for this setter as: 156 | 157 | .. code-block:: yaml 158 | 159 | e: Some other error message. 160 | 161 | Finally you can specify the specs of the property: 162 | 163 | .. code-block:: yaml 164 | 165 | specs: 166 | min: 1 167 | max: 100000 168 | type: float 169 | 170 | You can define the minimum (min) and maximum (max) values, and the type of the 171 | value (float, int, str). 172 | You can also specify the valid values, for example: 173 | 174 | .. code-block:: yaml 175 | 176 | specs: 177 | valid: [1, 3, 5] 178 | 179 | Notice that even if the type is a float, the communication is done with strings. 180 | 181 | 182 | randomized output 183 | ----------------- 184 | 185 | Both dialogs and properties can be configured to output random values. This can 186 | be used to simulate those instruments that returns measurements, such as DMM. 187 | 188 | The syntax is this: ``{RANDOM(min, max, num_of_results):<...>}`` 189 | 190 | The output will be one ore more random **float(s)** between ``min`` and ``max``. 191 | 192 | Below are a few examples. 193 | 194 | .. code-block:: yaml 195 | 196 | dialogues: 197 | - q: ":READ?" 198 | r: "{RANDOM(0, 4.55, 16):.5f}" 199 | 200 | 201 | .. code-block:: yaml 202 | 203 | properties: 204 | voltage: 205 | getter: 206 | q: ":VOLT?" 207 | r: "{RANDOM(0, 10, 1):.2f}" 208 | 209 | .. note:: 210 | 211 | Wrong syntax will raise the following exception: 212 | 213 | .. code-block:: console 214 | 215 | pyvisa-sim: Wrong RANDOM directive, see documentation for correct usage. 216 | 217 | 218 | resources 219 | --------- 220 | 221 | It is a dictionary that binds resource names to device types. The keys of this 222 | dictionary are the resource names which must be unique within this file. 223 | For example: 224 | 225 | .. code-block:: yaml 226 | 227 | resources: 228 | ASRL1::INSTR: 229 | device: device 1 230 | USB::0x1111::0x2222::0x1234::INSTR: 231 | device: device 1 232 | 233 | Within each resource, the type is specified under the **device** key. The 234 | associated value (e.g **device 1**) must corresponds to one of the keys in the 235 | **devices** dictionary that is explained above. Notice that the same device type 236 | can be bound to different resource names, creating two different objects of the 237 | same type. 238 | 239 | You can also bind a resource name to device defined in another file. Simply do: 240 | 241 | .. code-block:: yaml 242 | 243 | ASRL3::INSTR: 244 | device: device 1 245 | filename: myfile.yaml 246 | 247 | The path can specified in relation with the current file or in an absolute way. 248 | 249 | If you want to use a file which is bundled with PyVISA-sim, just write: 250 | 251 | .. code-block:: yaml 252 | 253 | ASRL3::INSTR: 254 | device: device 1 255 | filename: default.yaml 256 | bundled: true 257 | 258 | 259 | .. _YAML: http://en.wikipedia.org/wiki/YAML 260 | .. _`one provided with pyvisa-sim`: https://github.com/pyvisa/pyvisa-sim/blob/main/pyvisa_sim/default.yaml 261 | .. _`YAML online parser`: http://yaml-online-parser.appspot.com/ 262 | .. _PEP3101: https://www.python.org/dev/peps/pep-3101/ 263 | .. _`Lantz Example Driver`: https://lantz.readthedocs.org/en/0.3/tutorial/building.html 264 | .. _stringparser: https://github.com/hgrecco/stringparser 265 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | 4 | PyVISA-sim: Simulator backend for PyVISA 5 | ======================================== 6 | 7 | .. image:: _static/logo-full.jpg 8 | :alt: PyVISA 9 | 10 | 11 | PyVISA-sim is a backend for PyVISA_. It allows you to simulate devices 12 | and therefore test your applications without having real instruments connected. 13 | 14 | You can select the PyVISA-sim backend using **@sim** when instantiating the 15 | visa Resource Manager: 16 | 17 | >>> import pyvisa 18 | >>> rm = pyvisa.ResourceManager('@sim') 19 | >>> rm.list_resources() 20 | ('ASRL1::INSTR') 21 | >>> inst = rm.open_resource('ASRL1::INSTR', read_termination='\n') 22 | >>> print(inst.query("?IDN")) 23 | 24 | 25 | That's all! Except for **@sim**, the code is exactly what you would write in 26 | order to use the NI-VISA backend for PyVISA. 27 | 28 | If you want to load your own file instead of the default, specify the path 29 | prepended to the @sim string: 30 | 31 | >>> rm = pyvisa.ResourceManager('your_mock_here.yaml@sim') 32 | 33 | You can write your own simulators. See :ref:`definitions` to find out how. 34 | 35 | 36 | Installation 37 | ============ 38 | 39 | Using pip:: 40 | 41 | pip install -U pyvisa-sim 42 | 43 | You can report a problem or ask for features in the `issue tracker`_. 44 | 45 | .. _PyVISA: http://pyvisa.readthedocs.org/ 46 | .. _PyPI: https://pypi.python.org/pypi/PyVISA-sim 47 | .. _GitHub: https://github.com/pyvisa/pyvisa-sim 48 | .. _`issue tracker`: https://github.com/pyvisa/pyvisa-sim/issues 49 | 50 | 51 | User Guide 52 | ---------- 53 | 54 | .. toctree:: 55 | :maxdepth: 1 56 | 57 | definitions 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "PyVISA-sim" 3 | description = "Simulated backend for PyVISA implementing TCPIP, GPIB, RS232, and USB resources" 4 | readme = "README.rst" 5 | requires-python = ">=3.10" 6 | license = { file = "LICENSE.txt" } 7 | authors = [ 8 | { name = "Hernan E. Grecco", email = "hernan.grecco@gmail.com" }, 9 | ] 10 | maintainers = [ 11 | { name = "Matthieu C. Dartiailh", email = "m.dartiailh@gmail.com" }, 12 | ] 13 | keywords = [ 14 | "VISA", 15 | "GPIB", 16 | "USB", 17 | "serial", 18 | "RS232", 19 | "measurement", 20 | "acquisition", 21 | "simulator", 22 | "mock", 23 | ] 24 | classifiers = [ 25 | "Development Status :: 4 - Beta", 26 | "Intended Audience :: Developers", 27 | "Intended Audience :: Science/Research", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: Microsoft :: Windows", 30 | "Operating System :: POSIX :: Linux", 31 | "Operating System :: MacOS :: MacOS X", 32 | "Programming Language :: Python", 33 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | ] 40 | dependencies = [ 41 | "pyvisa>=1.15.0", 42 | "PyYAML", 43 | "stringparser", 44 | "typing-extensions", 45 | ] 46 | dynamic = ["version"] 47 | 48 | 49 | [project.urls] 50 | homepage = "https://github.com/pyvisa/pyvisa-sim" 51 | documentation = "https://pyvisa-sim.readthedocs.io/en/latest/" 52 | repository = "https://github.com/pyvisa/pyvisa-sim" 53 | changelog = "https://github.com/pyvisa/pyvisa-sim/blob/main/CHANGES.rst" 54 | 55 | [build-system] 56 | requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4.3"] 57 | build-backend = "setuptools.build_meta" 58 | 59 | [tool.setuptools_scm] 60 | write_to = "pyvisa_sim/version.py" 61 | write_to_template = """ 62 | # This file is auto-generated by setuptools-scm do NOT edit it. 63 | 64 | from collections import namedtuple 65 | 66 | #: A namedtuple of the version info for the current release. 67 | _version_info = namedtuple("_version_info", "major minor micro status") 68 | 69 | parts = "{version}".split(".", 3) 70 | version_info = _version_info( 71 | int(parts[0]), 72 | int(parts[1]), 73 | int(parts[2]), 74 | parts[3] if len(parts) == 4 else "", 75 | ) 76 | 77 | # Remove everything but the 'version_info' from this module. 78 | del namedtuple, _version_info, parts 79 | 80 | __version__ = "{version}" 81 | """ 82 | 83 | [tool.ruff] 84 | src = ["src"] 85 | extend-exclude = ["pyvisa/thirdparty/*"] 86 | line-length = 88 87 | 88 | [tool.ruff.lint] 89 | select = ["C", "E", "F", "W", "I", "C90", "RUF"] 90 | extend-ignore = ["E501", "RUF012"] 91 | 92 | [tool.ruff.lint.isort] 93 | combine-as-imports = true 94 | known-first-party = ["pyvisa"] 95 | 96 | [tool.ruff.lint.mccabe] 97 | max-complexity = 20 98 | 99 | [tool.pytest.ini_options] 100 | minversion = "6.0" 101 | 102 | [tool.mypy] 103 | follow_imports = "normal" 104 | strict_optional = true 105 | 106 | [[tool.mypy.overrides]] 107 | module = [ 108 | "stringparser", 109 | ] 110 | ignore_missing_imports = true 111 | 112 | [tool.coverage] 113 | [tool.coverage.run] 114 | branch = true 115 | source = ["pyvisa_sim"] 116 | 117 | [tool.coverage.report] 118 | # Regexes for lines to exclude from consideration 119 | exclude_lines = [ 120 | # Have to re-enable the standard pragma 121 | "pragma: no cover", 122 | 123 | # Don't complain if tests don't hit defensive assertion code: 124 | "raise NotImplementedError", 125 | "pass", 126 | 127 | # Don't complain about abstract methods, they aren't run: 128 | "@(abc\\.)?abstractmethod", 129 | 130 | # Don't complain about type checking 131 | "if TYPE_CHECKING:", 132 | 133 | # Don't complain about ellipsis in overload 134 | "\\.\\.\\.", 135 | ] 136 | -------------------------------------------------------------------------------- /pyvisa_sim/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Simulated backend for PyVISA. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from importlib.metadata import PackageNotFoundError, version 10 | 11 | from .highlevel import SimVisaLibrary 12 | 13 | __version__ = "unknown" 14 | try: 15 | __version__ = version(__name__) 16 | except PackageNotFoundError: 17 | # package is not installed 18 | pass 19 | 20 | 21 | WRAPPER_CLASS = SimVisaLibrary 22 | -------------------------------------------------------------------------------- /pyvisa_sim/channels.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Classes to enable the use of channels in devices. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from collections import defaultdict 10 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeVar 11 | 12 | import stringparser 13 | 14 | from .common import logger 15 | from .component import Component, OptionalBytes, OptionalStr, Property, T, to_bytes 16 | 17 | if TYPE_CHECKING: 18 | from .devices import Device 19 | 20 | 21 | class ChannelProperty(Property[T]): 22 | """A channel property storing the value for all channels.""" 23 | 24 | def __init__( 25 | self, channel: "Channels", name: str, default_value: str, specs: Dict[str, str] 26 | ) -> None: 27 | self._channel = channel 28 | super(ChannelProperty, self).__init__(name, default_value, specs) 29 | 30 | def init_value(self, string_value: str) -> None: 31 | """Create an empty defaultdict holding the default value.""" 32 | value = self.validate_value(string_value) 33 | self._value = defaultdict(lambda: value) 34 | 35 | def get_value(self) -> Optional[T]: 36 | """Get the current value for a channel.""" 37 | return self._value[self._channel._selected] 38 | 39 | def set_value(self, string_value: str) -> None: 40 | """Set the current value for a channel.""" 41 | value = self.validate_value(string_value) 42 | self._value[self._channel._selected] = value 43 | 44 | # --- Private API 45 | 46 | #: Reference to the channel holding that property. 47 | _channel: "Channels" 48 | 49 | #: Value of the property on a per channel basis 50 | _value: Dict[Any, T] # type: ignore 51 | 52 | 53 | V = TypeVar("V") 54 | 55 | 56 | class ChDict(Dict[str, Dict[bytes, V]]): 57 | """Default dict like creating specialized command sets for a channel.""" 58 | 59 | def __missing__(self, key: str) -> Dict[bytes, V]: 60 | """Create a channel specialized version of the mapping found in __default__.""" 61 | return { 62 | k.decode("utf-8").format(ch_id=key).encode("utf-8"): v 63 | for k, v in self["__default__"].items() 64 | } 65 | 66 | 67 | class Channels(Component): 68 | """A component representing a device channels.""" 69 | 70 | #: Flag indicating whether or not the channel can be selected inside 71 | #: the query or if it is pre-selected by a previous command. 72 | can_select: bool 73 | 74 | def __init__(self, device: "Device", ids: List[str], can_select: bool): 75 | super(Channels, self).__init__() 76 | self.can_select: bool = can_select 77 | self._selected = None 78 | self._device = device 79 | self._ids = ids 80 | self._getters = ChDict(__default__={}) 81 | self._dialogues = ChDict(__default__={}) 82 | 83 | def add_dialogue(self, query: str, response: str) -> None: 84 | """Add dialogue to channel. 85 | 86 | Parameters 87 | ---------- 88 | query : str 89 | Query string to which this dialogue answers to. 90 | response : str 91 | Response sent in response to a query. 92 | 93 | """ 94 | self._dialogues["__default__"][to_bytes(query)] = to_bytes(response) 95 | 96 | def add_property( 97 | self, 98 | name: str, 99 | default_value: str, 100 | getter_pair: Optional[Tuple[str, str]], 101 | setter_triplet: Optional[Tuple[str, OptionalStr, OptionalStr]], 102 | specs: Dict[str, str], 103 | ) -> None: 104 | """Add property to channel 105 | 106 | Parameters 107 | ---------- 108 | property_name : str 109 | Name of the property. 110 | default_value : str 111 | Default value of the property as a str. 112 | getter_pair : Optional[Tuple[str, str]] 113 | Parameters for accessing the property value (query and response str) 114 | setter_triplet : Optional[Tuple[str, OptionalStr, OptionalStr]] 115 | Parameters for setting the property value. The response and error 116 | are optional. 117 | specs : Dict[str, str] 118 | Specification for the property as a dict. 119 | 120 | """ 121 | self._properties[name] = ChannelProperty(self, name, default_value, specs) 122 | 123 | if getter_pair: 124 | query, response = getter_pair 125 | self._getters["__default__"][to_bytes(query)] = name, response 126 | 127 | if setter_triplet: 128 | query, response_, error = setter_triplet 129 | self._setters.append( 130 | (name, stringparser.Parser(query), to_bytes(response_), to_bytes(error)) 131 | ) 132 | 133 | def match(self, query: bytes) -> Optional[OptionalBytes]: 134 | """Try to find a match for a query in the channel commands.""" 135 | if not self.can_select: 136 | ch_id = self._device._properties["selected_channel"].get_value() 137 | if ch_id in self._ids: 138 | self._selected = ch_id 139 | else: 140 | return None 141 | 142 | response = self._match_dialog(query, self._dialogues["__default__"]) 143 | if response is not None: 144 | return response 145 | 146 | response = self._match_getters(query, self._getters["__default__"]) 147 | if response is not None: 148 | return response 149 | 150 | else: 151 | for ch_id in self._ids: 152 | self._selected = ch_id 153 | response = self._match_dialog(query, self._dialogues[ch_id]) 154 | if response is not None: 155 | return response 156 | 157 | response = self._match_getters(query, self._getters[ch_id]) 158 | 159 | if response is not None: 160 | return response 161 | 162 | return self._match_setters(query) 163 | 164 | # --- Private API 165 | 166 | #: Currently active channel, this can either reflect the currently 167 | #: selected channel on the device or the currently inspected possible 168 | #: when attempting to match. 169 | _selected: Optional[str] 170 | 171 | #: Reference to the parent device from which we might need to query and 172 | #: set the current selected channel 173 | _device: "Device" 174 | 175 | #: Ids of the activated channels. 176 | _ids: List[str] 177 | 178 | #: Dialogues organized by channel IDs 179 | _dialogues: Dict[str, Dict[bytes, bytes]] # type: ignore 180 | 181 | #: Getters organized by channel ID 182 | _getters: Dict[str, Dict[bytes, Tuple[str, str]]] # type: ignore 183 | 184 | def _match_setters(self, query: bytes) -> Optional[OptionalBytes]: 185 | """Try to find a match""" 186 | q = query.decode("utf-8") 187 | for name, parser, response, error_response in self._setters: 188 | try: 189 | parsed = parser(q) 190 | logger.debug("Found response in setter of %s" % name) 191 | except ValueError: 192 | continue 193 | 194 | try: 195 | if isinstance(parsed, dict) and "ch_id" in parsed: 196 | self._selected = parsed["ch_id"] 197 | self._properties[name].set_value(str(parsed["0"])) 198 | else: 199 | self._properties[name].set_value(str(parsed)) 200 | return response 201 | except ValueError: 202 | if isinstance(error_response, bytes): 203 | return error_response 204 | return self._device.error_response("command_error") 205 | 206 | return None 207 | -------------------------------------------------------------------------------- /pyvisa_sim/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Common tools. 3 | 4 | This code is currently taken from PyVISA-py. 5 | Do not edit here. 6 | 7 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 8 | :license: MIT, see LICENSE for more details. 9 | 10 | """ 11 | 12 | import logging 13 | from typing import Iterator, Optional 14 | 15 | from pyvisa import logger 16 | 17 | logger = logging.LoggerAdapter(logger, {"backend": "sim"}) # type: ignore 18 | 19 | 20 | def _create_bitmask(bits: int) -> int: 21 | """Create a bitmask for the given number of bits.""" 22 | mask = (1 << bits) - 1 23 | return mask 24 | 25 | 26 | def iter_bytes( 27 | data: bytes, data_bits: Optional[int] = None, send_end: Optional[bool] = None 28 | ) -> Iterator[bytes]: 29 | """Clip values to the correct number of bits per byte. 30 | 31 | Serial communication may use from 5 to 8 bits. 32 | 33 | Parameters 34 | ---------- 35 | data : The data to clip as a byte string. 36 | data_bits : How many bits per byte should be sent. Clip to this many bits. 37 | For example: data_bits=5: 0xff (0b1111_1111) --> 0x1f (0b0001_1111). 38 | Acceptable range is 5 to 8, inclusive. Values above 8 will be clipped to 8. 39 | This maps to the VISA attribute VI_ATTR_ASRL_DATA_BITS. 40 | send_end : 41 | If None (the default), apply the mask that is determined by data_bits. 42 | If False, apply the mask and set the highest (post-mask) bit to 0 for 43 | all bytes. 44 | If True, apply the mask and set the highest (post-mask) bit to 0 for 45 | all bytes except for the final byte, which has the highest bit set to 1. 46 | 47 | References 48 | ---------- 49 | + https://www.ivifoundation.org/downloads/Architecture%20Specifications/vpp43_2022-05-19.pdf, 50 | + https://www.ni.com/docs/en-US/bundle/ni-visa/page/ni-visa/vi_attr_asrl_data_bits.html, 51 | + https://www.ni.com/docs/en-US/bundle/ni-visa/page/ni-visa/vi_attr_asrl_end_out.html 52 | 53 | """ 54 | if send_end and data_bits is None: 55 | raise ValueError("'send_end' requires a valid 'data_bits' value.") 56 | 57 | if data_bits is None: 58 | for d in data: 59 | yield bytes([d]) 60 | else: 61 | if data_bits <= 0: 62 | raise ValueError("'data_bits' cannot be zero or negative") 63 | if data_bits > 8: 64 | data_bits = 8 65 | 66 | if send_end is None: 67 | # only apply the mask 68 | mask = _create_bitmask(data_bits) 69 | for d in data: 70 | yield bytes([d & mask]) 71 | elif bool(send_end) is False: 72 | # apply the mask and set highest bits to 0 73 | # This is effectively the same has reducing the mask by 1 bit. 74 | mask = _create_bitmask(data_bits - 1) 75 | for d in data: 76 | yield bytes([d & mask]) 77 | elif bool(send_end) is True: 78 | # apply the mask and set highest bits to 0 79 | # This is effectively the same has reducing the mask by 1 bit. 80 | mask = _create_bitmask(data_bits - 1) 81 | for d in data[:-1]: 82 | yield bytes([d & mask]) 83 | # except for the last byte which has it's highest bit set to 1. 84 | last_byte = data[-1] 85 | highest_bit = 1 << (data_bits - 1) 86 | yield bytes([(last_byte & mask) | highest_bit]) 87 | else: 88 | raise ValueError(f"Unknown 'send_end' value '{send_end}'") 89 | 90 | 91 | def int_to_byte(val: int) -> bytes: 92 | return bytes([val]) 93 | -------------------------------------------------------------------------------- /pyvisa_sim/component.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Base classes for devices parts. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | import enum 10 | import random 11 | import re 12 | from typing import ( 13 | Dict, 14 | Final, 15 | Generic, 16 | List, 17 | Literal, 18 | Optional, 19 | Set, 20 | Tuple, 21 | Type, 22 | TypeVar, 23 | Union, 24 | overload, 25 | ) 26 | 27 | import stringparser 28 | from typing_extensions import TypeAlias # not needed starting with 3.10 29 | 30 | from .common import logger 31 | 32 | 33 | # Sentinel enum which is the only 'clean' way to have sentinels and meaningful typing 34 | class Responses(enum.Enum): 35 | NO = object() 36 | 37 | 38 | NoResponse: Final = Responses.NO 39 | 40 | # Type aliases to be used when NoResponse is an acceptable value 41 | OptionalStr: TypeAlias = Union[str, Literal[Responses.NO]] 42 | OptionalBytes: TypeAlias = Union[bytes, Literal[Responses.NO]] 43 | 44 | 45 | @overload 46 | def to_bytes(val: str) -> bytes: ... 47 | 48 | 49 | @overload 50 | def to_bytes(val: Literal[Responses.NO]) -> Literal[Responses.NO]: ... 51 | 52 | 53 | def to_bytes(val): 54 | """Takes a text message or NoResponse and encode it.""" 55 | if val is NoResponse: 56 | return val 57 | 58 | val = val.replace("\\r", "\r").replace("\\n", "\n") 59 | return val.encode() 60 | 61 | 62 | T = TypeVar("T", int, float, str) 63 | 64 | 65 | def random_response(response: str) -> str: 66 | """ 67 | Return a response containing one or more random values. 68 | """ 69 | random_directive = re.findall( 70 | r"{RANDOM\((\d*.\d*), (\d*.\d*), (\d*)\).*}", response 71 | ) 72 | if len(random_directive) == 0: 73 | raise Exception( 74 | "pyvisa-sim: Wrong RANDOM directive, see documentation for correct usage." 75 | ) 76 | response = re.sub(r"RANDOM\((\d*.\d*), (\d*.\d*), (\d*)\)", "", response) 77 | min_value, max_value, num_of_results = random_directive[0] 78 | output_str = [] 79 | for i in range(int(num_of_results)): 80 | value = random.uniform(float(min_value), float(max_value)) 81 | output_str.append(response.format(value)) 82 | return ", ".join(output_str) 83 | 84 | 85 | class Specs(Generic[T]): 86 | """Specification to validate a property value. 87 | 88 | Parameters 89 | ---------- 90 | specs : Dict[str, str] 91 | Specs as a dictionary as extracted from the yaml config. 92 | 93 | """ 94 | 95 | #: Value that lead to some validation are int, float, str 96 | type: Type[T] 97 | 98 | #: Minimal admissible value 99 | min: Optional[T] 100 | 101 | #: Maximal admissible value 102 | max: Optional[T] 103 | 104 | #: Discrete set of valid values 105 | valid: Set[T] 106 | 107 | # FIXME add support for special values 108 | # some instrument support INCR DECR for increment decrement, 109 | # other support MIN, MAX, DEF 110 | 111 | def __init__(self, specs: Dict[str, str]) -> None: 112 | if "type" not in specs: 113 | raise ValueError("No property type was specified.") 114 | 115 | specs_type: Type[T] | None = None 116 | t = specs["type"] 117 | if t: 118 | for key, val in (("float", float), ("int", int), ("str", str)): 119 | if t == key: 120 | specs_type = val # type: ignore 121 | break 122 | 123 | if specs_type is None: 124 | raise ValueError( 125 | f"Invalid property type '{t}', valid types are: 'int', 'float', 'str'" 126 | ) 127 | self.type = specs_type 128 | 129 | self.min = self.type(specs["min"]) if "min" in specs else None 130 | self.max = self.type(specs["max"]) if "max" in specs else None 131 | self.valid = {self.type(val) for val in specs.get("valid", ())} 132 | 133 | 134 | class Property(Generic[T]): 135 | """A device property 136 | 137 | Parameters 138 | ---------- 139 | name : str 140 | Name of the property 141 | value : str 142 | Default value as a string 143 | specs : Dict[str, str] 144 | Specification used to validate the property value. 145 | 146 | """ 147 | 148 | #: Name of the property 149 | name: str 150 | 151 | #: Specification used to validate 152 | specs: Optional[Specs[T]] 153 | 154 | def __init__(self, name: str, value: str, specs: Dict[str, str]): 155 | self.name = name 156 | try: 157 | self.specs = Specs[T](specs) if specs else None 158 | except ValueError as e: 159 | raise ValueError(f"Failed to create Specs for property {name}") from e 160 | self._value = None 161 | self.init_value(value) 162 | 163 | def init_value(self, string_value: str) -> None: 164 | """Initialize the value hold by the Property.""" 165 | self.set_value(string_value) 166 | 167 | def get_value(self) -> Optional[T]: 168 | """Return the value stored by the Property.""" 169 | return self._value 170 | 171 | def set_value(self, string_value: str) -> None: 172 | """Set the value""" 173 | self._value = self.validate_value(string_value) 174 | 175 | def validate_value(self, string_value: str) -> T: 176 | """Validate that a value match the Property specs.""" 177 | specs = self.specs 178 | if specs is None: 179 | # This make str the default type 180 | return string_value # type: ignore 181 | 182 | assert specs.type 183 | value: T = specs.type(string_value) # type: ignore 184 | # Mypy dislike comparison with unresolved type vars it seems 185 | if specs.min is not None and value < specs.min: # type: ignore 186 | raise ValueError( 187 | f"Value provided for {self.name}: {value} " 188 | f"is less than the minimum {specs.min}" 189 | ) 190 | if specs.max is not None and value > specs.max: # type: ignore 191 | raise ValueError( 192 | f"Value provided for {self.name}: {value} " 193 | f"is more than the maximum {specs.max}" 194 | ) 195 | if specs.valid is not None and specs.valid and value not in specs.valid: 196 | raise ValueError( 197 | f"Value provide for {self.name}: {value}" 198 | f"Does not belong to the list of valid values: {specs.valid}" 199 | ) 200 | return value 201 | 202 | # --- Private API 203 | 204 | #: Current value of the property. 205 | _value: Optional[T] 206 | 207 | 208 | class Component: 209 | """A component of a device.""" 210 | 211 | def __init__(self) -> None: 212 | self._dialogues = {} 213 | self._properties = {} 214 | self._getters = {} 215 | self._setters = [] 216 | 217 | def add_dialogue(self, query: str, response: str) -> None: 218 | """Add dialogue to device. 219 | 220 | Parameters 221 | ---------- 222 | query : str 223 | Query to which the dialog answers to. 224 | response : str 225 | Response to the dialog query. 226 | 227 | """ 228 | self._dialogues[to_bytes(query)] = to_bytes(response) 229 | 230 | def add_property( 231 | self, 232 | name: str, 233 | default_value: str, 234 | getter_pair: Optional[Tuple[str, str]], 235 | setter_triplet: Optional[Tuple[str, OptionalStr, OptionalStr]], 236 | specs: Dict[str, str], 237 | ): 238 | """Add property to device 239 | 240 | Parameters 241 | ---------- 242 | property_name : str 243 | Name of the property. 244 | default_value : str 245 | Default value of the property as a str. 246 | getter_pair : Optional[Tuple[str, str]] 247 | Parameters for accessing the property value (query and response str) 248 | setter_triplet : Optional[Tuple[str, OptionalStr, OptionalStr]] 249 | Parameters for setting the property value. The response and error 250 | are optional. 251 | specs : Dict[str, str] 252 | Specification for the property as a dict. 253 | 254 | """ 255 | self._properties[name] = Property(name, default_value, specs) 256 | 257 | if getter_pair: 258 | query, response = getter_pair 259 | self._getters[to_bytes(query)] = name, response 260 | 261 | if setter_triplet: 262 | query, response_, error = setter_triplet 263 | self._setters.append( 264 | (name, stringparser.Parser(query), to_bytes(response_), to_bytes(error)) 265 | ) 266 | 267 | def match(self, query: bytes) -> Optional[OptionalBytes]: 268 | """Try to find a match for a query in the instrument commands.""" 269 | raise NotImplementedError() 270 | 271 | # --- Private API 272 | 273 | #: Stores the queries accepted by the device. 274 | #: query: response 275 | _dialogues: Dict[bytes, bytes] 276 | 277 | #: Maps property names to value, type, validator 278 | _properties: Dict[str, Property] 279 | 280 | #: Stores the getter queries accepted by the device. 281 | #: query: (property_name, response) 282 | _getters: Dict[bytes, Tuple[str, str]] 283 | 284 | #: Stores the setters queries accepted by the device. 285 | #: (property_name, string parser query, response, error response) 286 | _setters: List[Tuple[str, stringparser.Parser, OptionalBytes, OptionalBytes]] 287 | 288 | def _match_dialog( 289 | self, query: bytes, dialogues: Optional[Dict[bytes, bytes]] = None 290 | ) -> Optional[bytes]: 291 | """Tries to match in dialogues 292 | 293 | Parameters 294 | ---------- 295 | query : bytes 296 | Query that we try to match to. 297 | dialogues : Optional[Dict[bytes, bytes]], optional 298 | Alternative dialogs to use when matching. 299 | 300 | Returns 301 | ------- 302 | Optional[bytes] 303 | Response if a dialog matched. 304 | 305 | """ 306 | if dialogues is None: 307 | dialogues = self._dialogues 308 | 309 | # Try to match in the queries 310 | if query in dialogues: 311 | response = dialogues[query] 312 | logger.debug("Found response in queries: %s" % repr(response)) 313 | 314 | if "RANDOM" in response.decode("utf-8"): 315 | response = random_response(response.decode("utf-8")).encode("utf-8") 316 | 317 | return response 318 | 319 | return None 320 | 321 | def _match_getters( 322 | self, 323 | query: bytes, 324 | getters: Optional[Dict[bytes, Tuple[str, str]]] = None, 325 | ) -> Optional[bytes]: 326 | """Tries to match in getters 327 | 328 | Parameters 329 | ---------- 330 | query : bytes 331 | Query that we try to match to. 332 | dialogues : Optional[Dict[bytes, bytes]], optional 333 | Alternative getters to use when matching. 334 | 335 | Returns 336 | ------- 337 | Optional[bytes] 338 | Response if a dialog matched. 339 | 340 | """ 341 | if getters is None: 342 | getters = self._getters 343 | 344 | if query in getters: 345 | name, response = getters[query] 346 | logger.debug("Found response in getter of %s" % name) 347 | 348 | if "RANDOM" in response: 349 | response = random_response(response) 350 | else: 351 | value = self._properties[name].get_value() 352 | response = response.format(value) 353 | 354 | return response.encode("utf-8") 355 | 356 | return None 357 | 358 | def _match_setters(self, query: bytes) -> Optional[OptionalBytes]: 359 | """Tries to match in setters 360 | 361 | Parameters 362 | ---------- 363 | query : bytes 364 | Query that we try to match to. 365 | 366 | Returns 367 | ------- 368 | Optional[bytes] 369 | Response if a dialog matched. 370 | 371 | """ 372 | q = query.decode("utf-8") 373 | for name, parser, response, error_response in self._setters: 374 | try: 375 | value = parser(q) 376 | logger.debug("Found response in setter of %s" % name) 377 | except ValueError: 378 | continue 379 | 380 | try: 381 | self._properties[name].set_value(value) 382 | return response 383 | except ValueError: 384 | if isinstance(error_response, bytes): 385 | return error_response 386 | 387 | return None 388 | -------------------------------------------------------------------------------- /pyvisa_sim/default.yaml: -------------------------------------------------------------------------------- 1 | spec: "1.0" 2 | devices: 3 | device 1: 4 | eom: 5 | ASRL INSTR: 6 | q: "\r\n" 7 | r: "\n" 8 | USB INSTR: 9 | q: "\n" 10 | r: "\n" 11 | TCPIP INSTR: 12 | q: "\n" 13 | r: "\n" 14 | TCPIP SOCKET: 15 | q: "\n" 16 | r: "\n" 17 | GPIB INSTR: 18 | q: "\n" 19 | r: "\n" 20 | error: ERROR 21 | dialogues: 22 | - q: "?IDN" 23 | r: "LSG Serial #1234" 24 | - q: "!CAL" 25 | r: OK 26 | properties: 27 | frequency: 28 | default: 100.0 29 | getter: 30 | q: "?FREQ" 31 | r: "{:.2f}" 32 | setter: 33 | q: "!FREQ {:.2f}" 34 | r: OK 35 | e: 'FREQ_ERROR' 36 | specs: 37 | min: 1 38 | max: 100000 39 | type: float 40 | amplitude: 41 | default: 1.0 42 | getter: 43 | q: "?AMP" 44 | r: "{:.2f}" 45 | setter: 46 | q: "!AMP {:.2f}" 47 | r: OK 48 | specs: 49 | min: 0 50 | max: 10 51 | type: float 52 | offset: 53 | default: 0 54 | getter: 55 | q: "?OFF" 56 | r: "{:.2f}" 57 | setter: 58 | q: "!OFF {:.2f}" 59 | r: OK 60 | specs: 61 | min: 0 62 | max: 10 63 | type: float 64 | output_enabled: 65 | default: 0 66 | getter: 67 | q: "?OUT" 68 | r: "{:d}" 69 | setter: 70 | q: "!OUT {:d}" 71 | r: OK 72 | specs: 73 | valid: [0, 1] 74 | type: int 75 | waveform: 76 | default: 0 77 | getter: 78 | q: "?WVF" 79 | r: "{:d}" 80 | setter: 81 | q: "!WVF {:d}" 82 | r: OK 83 | specs: 84 | valid: [0, 1, 2, 3] 85 | type: int 86 | device 2: 87 | eom: 88 | ASRL INSTR: 89 | q: "\r\n" 90 | r: "\n" 91 | USB INSTR: 92 | q: "\n" 93 | r: "\n" 94 | TCPIP INSTR: 95 | q: "\n" 96 | r: "\n" 97 | GPIB INSTR: 98 | q: "\n" 99 | r: "\n" 100 | dialogues: 101 | - q: "*IDN?" 102 | r: "SCPI,MOCK,VERSION_1.0" 103 | error: 104 | status_register: 105 | - q: "*ESR?" 106 | command_error: 32 107 | query_error: 4 108 | properties: 109 | voltage: 110 | default: 1.0 111 | getter: 112 | q: ":VOLT:IMM:AMPL?" 113 | r: "{:+.8E}" 114 | setter: 115 | q: ":VOLT:IMM:AMPL {:.3f}" 116 | specs: 117 | min: 1 118 | max: 6 119 | type: float 120 | current: 121 | default: 1.0 122 | getter: 123 | q: ":CURR:IMM:AMPL?" 124 | r: "{:+.8E}" 125 | setter: 126 | q: ":CURR:IMM:AMPL {:.3f}" 127 | specs: 128 | min: 1 129 | max: 6 130 | type: float 131 | rail: 132 | default: P6V 133 | getter: 134 | q: "INST?" 135 | r: "{:s}" 136 | setter: 137 | q: "INST {:s}" 138 | specs: 139 | valid: ["P6V", "P25V", "N25V"] 140 | type: str 141 | output_enabled: 142 | default: 0 143 | getter: 144 | q: "OUTP?" 145 | r: "{:d}" 146 | setter: 147 | q: "OUTP {:d}" 148 | specs: 149 | valid: [0, 1] 150 | type: int 151 | device 3: 152 | eom: 153 | ASRL INSTR: 154 | q: "\r\n" 155 | r: "\n" 156 | USB INSTR: 157 | q: "\n" 158 | r: "\n" 159 | TCPIP INSTR: 160 | q: "\n" 161 | r: "\n" 162 | GPIB INSTR: 163 | q: "\n" 164 | r: "\n" 165 | dialogues: 166 | - q: "*IDN?" 167 | r: "SCPI,MOCK,VERSION_1.0" 168 | error: 169 | response: 170 | command_error: "INVALID_COMMAND" 171 | status_register: 172 | - q: "*ESR?" 173 | command_error: 32 174 | query_error: 4 175 | properties: 176 | voltage: 177 | default: 1.0 178 | getter: 179 | q: ":VOLT:IMM:AMPL?" 180 | r: "{:+.8E}" 181 | setter: 182 | q: ":VOLT:IMM:AMPL {:.3f}" 183 | specs: 184 | min: 1 185 | max: 6 186 | type: float 187 | current: 188 | default: 1.0 189 | getter: 190 | q: ":CURR:IMM:AMPL?" 191 | r: "{:+.8E}" 192 | setter: 193 | q: ":CURR:IMM:AMPL {:.3f}" 194 | specs: 195 | min: 1 196 | max: 6 197 | type: float 198 | read_only: 199 | default: P6V 200 | getter: 201 | q: "INST?" 202 | r: "{:s}" 203 | output_enabled: 204 | default: 0 205 | getter: 206 | q: "OUTP?" 207 | r: "{:d}" 208 | setter: 209 | q: "OUTP {:d}" 210 | device 4: 211 | eom: 212 | ASRL INSTR: 213 | q: "\r\n" 214 | r: "\n" 215 | USB INSTR: 216 | q: "\n" 217 | r: "\n" 218 | TCPIP INSTR: 219 | q: "\n" 220 | r: "\n" 221 | GPIB INSTR: 222 | q: "\n" 223 | r: "\n" 224 | dialogues: 225 | - q: "*IDN?" 226 | r: "SCPI,MOCK,VERSION_1.0" 227 | error: 228 | error_queue: 229 | - q: ':SYST:ERR?' 230 | default: '0, No Error' 231 | command_error: '1, Command error' 232 | properties: 233 | voltage: 234 | default: 1.0 235 | getter: 236 | q: ":VOLT:IMM:AMPL?" 237 | r: "{:+.8E}" 238 | setter: 239 | q: ":VOLT:IMM:AMPL {:.3f}" 240 | specs: 241 | min: 1 242 | max: 6 243 | type: float 244 | device 5: 245 | eom: 246 | ASRL INSTR: 247 | q: "\r\n" 248 | r: "\n" 249 | USB INSTR: 250 | q: "\n" 251 | r: "\n" 252 | TCPIP INSTR: 253 | q: "\n" 254 | r: "\n" 255 | GPIB INSTR: 256 | q: "\n" 257 | r: "\n" 258 | dialogues: 259 | - q: ":READ?" 260 | r: "{RANDOM(0, 10.5, 1):.2f}" 261 | - q: ":SCAN?" 262 | r: "{RANDOM(0, 10.5, 5):.2f}" 263 | - q: ":BAD:SCAN:OUTSIDE?" 264 | r: "RANDOM(0, 10.5, 5){:.2f}" 265 | - q: ":BAD:SCAN:INSIDE?" 266 | r: "{RANDOM(0, 10.5):.2f}" 267 | error: 268 | error_queue: 269 | - q: ':SYST:ERR?' 270 | default: '0, No Error' 271 | command_error: '1, Command error' 272 | properties: 273 | voltage: 274 | default: 1.0 275 | getter: 276 | q: ":VOLT:IMM:AMPL?" 277 | r: "{RANDOM(-5, 5, 1):.2f}" 278 | setter: 279 | q: ":VOLT:IMM:AMPL {:.3f}" 280 | specs: 281 | min: 1 282 | max: 6 283 | type: float 284 | 285 | resources: 286 | ASRL1::INSTR: 287 | device: device 1 288 | USB::0x1111::0x2222::0x1234::INSTR: 289 | device: device 1 290 | TCPIP::localhost::INSTR: 291 | device: device 1 292 | TCPIP::localhost::10001::SOCKET: 293 | device: device 1 294 | GPIB::8::INSTR: 295 | device: device 1 296 | ASRL2::INSTR: 297 | device: device 2 298 | USB::0x1111::0x2222::0x2468::INSTR: 299 | device: device 2 300 | TCPIP::localhost:2222::INSTR: 301 | device: device 2 302 | GPIB::9::INSTR: 303 | device: device 2 304 | ASRL3::INSTR: 305 | device: device 3 306 | USB::0x1111::0x2222::0x3692::INSTR: 307 | device: device 3 308 | TCPIP::localhost:3333::INSTR: 309 | device: device 3 310 | GPIB::10::INSTR: 311 | device: device 3 312 | ASRL4::INSTR: 313 | device: device 4 314 | USB::0x1111::0x2222::0x4444::INSTR: 315 | device: device 4 316 | TCPIP::localhost:4444::INSTR: 317 | device: device 4 318 | GPIB::4::INSTR: 319 | device: device 4 320 | ASRL5::INSTR: 321 | device: device 5 322 | USB::0x1111::0x2222::0x5555::INSTR: 323 | device: device 5 324 | TCPIP::localhost:5555::INSTR: 325 | device: device 5 326 | GPIB::5::INSTR: 327 | device: device 5 328 | USB::0x1111::0x2222::0x4445::RAW: 329 | device: device 1 330 | 331 | -------------------------------------------------------------------------------- /pyvisa_sim/devices.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Classes to simulate devices. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from typing import Deque, Dict, List, Optional, Tuple, Union 10 | 11 | from pyvisa import constants, rname 12 | 13 | from .channels import Channels 14 | from .common import int_to_byte, logger 15 | from .component import Component, NoResponse, OptionalBytes, to_bytes 16 | 17 | 18 | class StatusRegister: 19 | """Class used to mimic a register. 20 | 21 | Parameters 22 | ---------- 23 | values : values: Dict[str, int] 24 | Mapping between a name and the associated integer value. 25 | The name 'q' is reserved and ignored. 26 | 27 | """ 28 | 29 | def __init__(self, values: Dict[str, int]) -> None: 30 | self._value = 0 31 | self._error_map = {} 32 | for name, value in values.items(): 33 | if name == "q": 34 | continue 35 | self._error_map[name] = int(value) 36 | 37 | def set(self, error_key: str) -> None: 38 | self._value = self._value | self._error_map[error_key] 39 | 40 | def keys(self) -> List[str]: 41 | return list(self._error_map.keys()) 42 | 43 | @property 44 | def value(self) -> bytes: 45 | return to_bytes(str(self._value)) 46 | 47 | def clear(self) -> None: 48 | self._value = 0 49 | 50 | # --- Private API 51 | 52 | #: Mapping between name and integer values. 53 | _error_map: Dict[str, int] 54 | 55 | #: Current value of the register. 56 | _value: int 57 | 58 | 59 | class ErrorQueue: 60 | """Store error messages in a FIFO queue. 61 | 62 | Parameters 63 | ---------- 64 | values : values: Dict[str, str] 65 | Mapping between a name and the associated detailed error message. 66 | The names 'q', 'default' and 'strict' are reserved. 67 | 'q' and 'strict' are ignored, 'default' is used to set up the default 68 | response when the queue is empty. 69 | 70 | """ 71 | 72 | def __init__(self, values: Dict[str, str]) -> None: 73 | self._queue: List[bytes] = [] 74 | self._error_map = {} 75 | for name, value in values.items(): 76 | if name in ("q", "default", "strict"): 77 | continue 78 | self._error_map[name] = to_bytes(value) 79 | self._default = to_bytes(values["default"]) 80 | 81 | def append(self, err: str) -> None: 82 | if err in self._error_map: 83 | self._queue.append(self._error_map[err]) 84 | 85 | @property 86 | def value(self) -> bytes: 87 | if self._queue: 88 | return self._queue.pop(0) 89 | else: 90 | return self._default 91 | 92 | def clear(self) -> None: 93 | self._queue = [] 94 | 95 | # --- Private API 96 | 97 | #: Queue of recorded errors 98 | _queue: List[bytes] 99 | 100 | #: Mapping between short error names and complete error messages 101 | _error_map: Dict[str, bytes] 102 | 103 | #: Default response when the queue is empty. 104 | _default: bytes 105 | 106 | 107 | class Device(Component): 108 | """A representation of a responsive device 109 | 110 | Parameters 111 | ---------- 112 | name : str 113 | The identification name of the device 114 | delimiter : bytes 115 | Character delimiting multiple message sent in a single query. 116 | 117 | """ 118 | 119 | #: Name of the device. 120 | name: str 121 | 122 | #: Special character use to delimit multiple messages. 123 | delimiter: bytes 124 | 125 | def __init__(self, name: str, delimiter: bytes) -> None: 126 | super(Device, self).__init__() 127 | self.name = name 128 | self.delimiter = delimiter 129 | self._resource_name = None 130 | self._query_eom = b"" 131 | self._response_eom = b"" 132 | self._channels = {} 133 | self._error_response = {} 134 | self._status_registers = {} 135 | self._error_map = {} 136 | self._eoms = {} 137 | self._output_buffers = Deque() 138 | self._input_buffer = bytearray() 139 | self._error_queues = {} 140 | 141 | @property 142 | def resource_name(self) -> Optional[str]: 143 | """Assigned resource name""" 144 | return self._resource_name 145 | 146 | @resource_name.setter 147 | def resource_name(self, value: str) -> None: 148 | p = rname.parse_resource_name(value) 149 | self._resource_name = str(p) 150 | try: 151 | self._query_eom, self._response_eom = self._eoms[ 152 | (p.interface_type_const, p.resource_class) 153 | ] 154 | except KeyError: 155 | logger.warning( 156 | "No eom provided for %s, %s." 157 | "Using LF." % (p.interface_type_const, p.resource_class) 158 | ) 159 | self._query_eom, self._response_eom = b"\n", b"\n" 160 | 161 | def add_channels(self, ch_name: str, ch_obj: Channels) -> None: 162 | """Add a channel definition.""" 163 | self._channels[ch_name] = ch_obj 164 | 165 | # FIXME use a TypedDict 166 | def add_error_handler(self, error_input: Union[dict, str]): 167 | """Add error handler to the device""" 168 | 169 | if isinstance(error_input, dict): 170 | error_response = error_input.get("response", {}) 171 | cerr = error_response.get("command_error", NoResponse) 172 | qerr = error_response.get("query_error", NoResponse) 173 | 174 | response_dict = {"command_error": cerr, "query_error": qerr} 175 | 176 | register_list = error_input.get("status_register", []) 177 | 178 | for register_dict in register_list: 179 | query = register_dict["q"] 180 | register = StatusRegister(register_dict) 181 | self._status_registers[to_bytes(query)] = register 182 | for key in register.keys(): 183 | self._error_map[key] = register 184 | 185 | queue_list = error_input.get("error_queue", []) 186 | 187 | for queue_dict in queue_list: 188 | query = queue_dict["q"] 189 | err_queue = ErrorQueue(queue_dict) 190 | self._error_queues[to_bytes(query)] = err_queue 191 | 192 | else: 193 | response_dict = {"command_error": error_input, "query_error": error_input} 194 | 195 | for key, value in response_dict.items(): 196 | self._error_response[key] = to_bytes(value) 197 | 198 | def error_response(self, error_key: str) -> Optional[bytes]: 199 | """Uupdate all error queues and return an error message if it exists.""" 200 | if error_key in self._error_map: 201 | self._error_map[error_key].set(error_key) 202 | 203 | for q in self._error_queues.values(): 204 | q.append(error_key) 205 | 206 | return self._error_response.get(error_key) 207 | 208 | def add_eom( 209 | self, type_class: str, query_termination: str, response_termination: str 210 | ) -> None: 211 | """Add default end of message for a given interface type and resource class. 212 | 213 | Parameters 214 | ---------- 215 | type_class : str 216 | Interface type and resource class as strings joined by space 217 | query_termination : str 218 | End of message used in queries. 219 | response_termination : str 220 | End of message used in responses. 221 | 222 | """ 223 | i_t, resource_class = type_class.split(" ") 224 | interface_type = getattr(constants.InterfaceType, i_t.lower()) 225 | self._eoms[(interface_type, resource_class)] = ( 226 | to_bytes(query_termination), 227 | to_bytes(response_termination), 228 | ) 229 | 230 | def write(self, data: bytes) -> None: 231 | """Write data into the device input buffer.""" 232 | logger.debug("Writing into device input buffer: %r" % data) 233 | if not isinstance(data, bytes): 234 | raise TypeError("data must be an instance of bytes") 235 | 236 | self._input_buffer.extend(data) 237 | 238 | le = len(self._query_eom) 239 | if not self._input_buffer.endswith(self._query_eom): 240 | return 241 | 242 | try: 243 | message = bytes(self._input_buffer[:-le]) 244 | queries = message.split(self.delimiter) if self.delimiter else [message] 245 | for query in queries: 246 | response = self._match(query) 247 | eom = self._response_eom 248 | 249 | if response is None: 250 | response = self.error_response("command_error") 251 | assert response is not None 252 | 253 | if response is not NoResponse: 254 | self._output_buffers.append(bytearray(response) + eom) 255 | 256 | finally: 257 | self._input_buffer = bytearray() 258 | 259 | def read(self) -> Tuple[bytes, bool]: 260 | """ 261 | Return a single byte from the output buffer and whether it is accompanied by an 262 | END indicator. 263 | """ 264 | if not self._output_buffers: 265 | return b"", False 266 | 267 | output_buffer = self._output_buffers[0] 268 | b = int_to_byte(output_buffer.pop(0)) 269 | if output_buffer: 270 | return b, False 271 | else: 272 | self._output_buffers.popleft() 273 | return b, True 274 | 275 | # --- Private API 276 | 277 | #: Resource name this device is bound to. Set when adding the device to Devices 278 | _resource_name: Optional[str] 279 | 280 | # Default end of message used in query operations 281 | _query_eom: bytes 282 | 283 | # Default end of message used in response operations 284 | _response_eom: bytes 285 | 286 | #: Mapping between a name and a Channels object 287 | _channels: Dict[str, Channels] 288 | 289 | #: Stores the error response for each query accepted by the device. 290 | _error_response: Dict[str, bytes] 291 | 292 | #: Stores the registers by name. 293 | #: Register name -> Register object 294 | _status_registers: Dict[bytes, StatusRegister] 295 | 296 | #: Mapping between error and register affected by the error. 297 | _error_map: Dict[str, StatusRegister] 298 | 299 | #: Stores the specific end of messages for device. 300 | #: TYPE CLASS -> (query termination, response termination) 301 | _eoms: Dict[Tuple[constants.InterfaceType, str], Tuple[bytes, bytes]] 302 | 303 | #: Deque of buffers in which the user can read 304 | _output_buffers: Deque[bytearray] 305 | 306 | #: Buffer in which the user can write 307 | _input_buffer: bytearray 308 | 309 | #: Mapping an error queue query and the queue. 310 | _error_queues: Dict[bytes, ErrorQueue] 311 | 312 | def _match(self, query: bytes) -> Optional[OptionalBytes]: 313 | """Tries to match in dialogues, getters and setters and channels.""" 314 | response: Optional[OptionalBytes] 315 | response = self._match_dialog(query) 316 | if response is not None: 317 | return response 318 | 319 | response = self._match_getters(query) 320 | if response is not None: 321 | return response 322 | 323 | response = self._match_registers(query) 324 | if response is not None: 325 | return response 326 | 327 | response = self._match_errors_queues(query) 328 | if response is not None: 329 | return response 330 | 331 | response = self._match_setters(query) 332 | if response is not None: 333 | return response 334 | 335 | if response is None: 336 | for channel in self._channels.values(): 337 | response = channel.match(query) 338 | if response: 339 | return response 340 | 341 | return None 342 | 343 | def _match_registers(self, query: bytes) -> Optional[bytes]: 344 | """Tries to match in status registers.""" 345 | if query in self._status_registers: 346 | register = self._status_registers[query] 347 | response = register.value 348 | logger.debug("Found response in status register: %s", repr(response)) 349 | register.clear() 350 | 351 | return response 352 | 353 | return None 354 | 355 | def _match_errors_queues(self, query: bytes) -> Optional[bytes]: 356 | """Tries to match in error queues.""" 357 | if query in self._error_queues: 358 | queue = self._error_queues[query] 359 | response = queue.value 360 | logger.debug("Found response in error queue: %s", repr(response)) 361 | 362 | return response 363 | 364 | return None 365 | 366 | 367 | class Devices: 368 | """The group of connected devices.""" 369 | 370 | def __init__(self) -> None: 371 | self._internal = {} 372 | 373 | def add_device(self, resource_name: str, device: Device) -> None: 374 | """Bind device to resource name""" 375 | 376 | if device.resource_name is not None: 377 | msg = "The device %r is already assigned to %s" 378 | raise ValueError(msg % (device, device.resource_name)) 379 | 380 | device.resource_name = resource_name 381 | 382 | self._internal[resource_name] = device 383 | 384 | def __getitem__(self, item: str) -> Device: 385 | return self._internal[item] 386 | 387 | def list_resources(self) -> Tuple[str, ...]: 388 | """List resource names. 389 | 390 | :rtype: tuple[str] 391 | """ 392 | return tuple(self._internal.keys()) 393 | 394 | # --- Private API 395 | 396 | #: Resource name to device map. 397 | _internal: Dict[str, Device] 398 | -------------------------------------------------------------------------------- /pyvisa_sim/highlevel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Simulated VISA Library. 3 | 4 | :copyright: 2014 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | import random 10 | from collections import OrderedDict 11 | from traceback import format_exc 12 | from typing import Any, Dict, SupportsInt, Tuple, Union, overload 13 | 14 | import pyvisa.errors as errors 15 | from pyvisa import constants, highlevel, rname 16 | from pyvisa.typing import VISAEventContext, VISARMSession, VISASession 17 | from pyvisa.util import LibraryPath 18 | 19 | # This import is required to register subclasses 20 | from . import parser 21 | from .sessions import gpib, serial, tcpip, usb # noqa 22 | from .sessions.session import Session 23 | 24 | 25 | class SimVisaLibrary(highlevel.VisaLibraryBase): 26 | """A pure Python backend for PyVISA. 27 | 28 | The object is basically a dispatcher with some common functions implemented. 29 | 30 | When a new resource object is requested to pyvisa, the library creates a Session 31 | object (that knows how to perform low-level communication operations) associated 32 | with a session handle (a number, usually referred just as session). 33 | 34 | A call to a library function is handled by PyVisaLibrary if it involves a resource 35 | agnostic function or dispatched to the correct session object (obtained from the 36 | session id). 37 | 38 | Importantly, the user is unaware of this. PyVisaLibrary behaves for the user 39 | just as an IVIVisaLibrary. 40 | 41 | """ 42 | 43 | #: Maps session handle to session objects. 44 | sessions: Dict[VISASession, Session] 45 | 46 | @staticmethod 47 | def get_library_paths() -> Tuple[LibraryPath]: 48 | """List a dummy library path to allow to create the library.""" 49 | return (LibraryPath("unset"),) 50 | 51 | @staticmethod 52 | def get_debug_info() -> Dict[str, str]: 53 | """Return a list of lines with backend info.""" 54 | from . import __version__ 55 | from .parser import SPEC_VERSION 56 | 57 | d = OrderedDict() 58 | d["Version"] = "%s" % __version__ 59 | d["Spec version"] = SPEC_VERSION 60 | 61 | return d 62 | 63 | def _init(self) -> None: 64 | self.sessions: Dict[int, Session] = {} 65 | try: 66 | if self.library_path == "unset": 67 | self.devices = parser.get_devices("default.yaml", True) 68 | else: 69 | self.devices = parser.get_devices(self.library_path, False) 70 | except Exception as e: 71 | msg = "Could not parse definitions file. %r" 72 | raise type(e)(msg % format_exc()) 73 | 74 | @overload 75 | def _register(self, obj: "SimVisaLibrary") -> VISARMSession: ... 76 | 77 | @overload 78 | def _register(self, obj: Session) -> VISASession: ... 79 | 80 | def _register(self, obj): 81 | """Creates a random but unique session handle for a session object. 82 | 83 | The handle is registered it in the sessions dictionary and returned. 84 | 85 | """ 86 | session = None 87 | 88 | while session is None or session in self.sessions: 89 | session = random.randint(1000000, 9999999) 90 | 91 | self.sessions[session] = obj 92 | return session 93 | 94 | def open( 95 | self, 96 | session: VISARMSession, 97 | resource_name: str, 98 | access_mode: constants.AccessModes = constants.AccessModes.no_lock, 99 | open_timeout: SupportsInt = constants.VI_TMO_IMMEDIATE, 100 | ) -> Tuple[VISASession, constants.StatusCode]: 101 | """Opens a session to the specified resource. 102 | 103 | Corresponds to viOpen function of the VISA library. 104 | 105 | Parameters 106 | ---------- 107 | sessions : VISARMSession 108 | Resource Manager session (should always be a session returned 109 | from open_default_resource_manager()). 110 | resource_name : str 111 | Unique symbolic name of a resource. 112 | access_mode : constants.AccessModes 113 | Specifies the mode by which the resource is to be accessed. 114 | open_timeout : int 115 | Specifies the maximum time period (in milliseconds) that this operation 116 | waits before returning an error. 117 | 118 | Returns 119 | ------- 120 | VISASession 121 | Unique logical identifier reference to a session, return value of the 122 | library call. 123 | constants.StatusCode 124 | Status code describing the operation execution. 125 | 126 | """ 127 | 128 | try: 129 | open_timeout = int(open_timeout) 130 | except ValueError: 131 | raise ValueError( 132 | "open_timeout (%r) must be an integer (or compatible type)" 133 | % open_timeout 134 | ) 135 | 136 | try: 137 | parsed = rname.parse_resource_name(resource_name) 138 | except rname.InvalidResourceName: 139 | return VISASession(0), constants.StatusCode.error_invalid_resource_name 140 | 141 | # Loops through all session types, tries to parse the resource name and if ok, open it. 142 | cls = Session.get_session_class( 143 | parsed.interface_type_const, parsed.resource_class 144 | ) 145 | 146 | sess = cls(session, resource_name, parsed) 147 | 148 | try: 149 | r_name = sess.attrs[constants.ResourceAttribute.resource_name] 150 | assert isinstance(r_name, str) 151 | sess.device = self.devices[r_name] 152 | except KeyError: 153 | return VISASession(0), constants.StatusCode.error_resource_not_found 154 | 155 | return self._register(sess), constants.StatusCode.success 156 | 157 | def close( 158 | self, session: Union[VISASession, VISARMSession, VISAEventContext] 159 | ) -> constants.StatusCode: 160 | """Closes the specified session, event, or find list. 161 | 162 | Corresponds to viClose function of the VISA library. 163 | 164 | Parameters 165 | ---------- 166 | session : Union[VISASession, VISARMSession, VISAEventContext] 167 | Unique logical identifier to a session, event, or find list. 168 | 169 | Returns 170 | ------- 171 | constants.StatusCode 172 | Return value of the library call. 173 | 174 | """ 175 | try: 176 | del self.sessions[session] # type: ignore 177 | return constants.StatusCode.success 178 | except KeyError: 179 | return constants.StatusCode.error_invalid_object 180 | 181 | def open_default_resource_manager( 182 | self, 183 | ) -> Tuple[VISARMSession, constants.StatusCode]: 184 | """This function returns a session to the Default Resource Manager resource. 185 | 186 | Corresponds to viOpenDefaultRM function of the VISA library. 187 | 188 | Returns 189 | ------- 190 | VISARMSession 191 | Unique logical identifier to a Default Resource Manager session, return 192 | value of the library call. 193 | constants.StatusCode 194 | Return value of the library call. 195 | 196 | """ 197 | return self._register(self), constants.StatusCode.success 198 | 199 | def list_resources( 200 | self, session: VISARMSession, query: str = "?*::INSTR" 201 | ) -> Tuple[str, ...]: 202 | """Returns a tuple of all connected devices matching query. 203 | 204 | Parameters 205 | ---------- 206 | session : VISARMSession 207 | Resource manager session 208 | query : str 209 | VISA regular expression used to match devices. 210 | 211 | """ 212 | # For each session type, ask for the list of connected resources and merge 213 | # them into a single list. 214 | resources = self.devices.list_resources() 215 | 216 | resources = rname.filter(resources, query) 217 | 218 | if resources: 219 | return resources 220 | 221 | raise errors.VisaIOError(errors.StatusCode.error_resource_not_found.value) 222 | 223 | def read( 224 | self, session: VISASession, count: int 225 | ) -> Tuple[bytes, constants.StatusCode]: 226 | """Reads data from device or interface synchronously. 227 | 228 | Corresponds to viRead function of the VISA library. 229 | 230 | Parameters 231 | ---------- 232 | session : VISASession 233 | Unique logical identifier to a session. 234 | count : int 235 | Number of bytes to be read. 236 | 237 | Returns 238 | ------- 239 | bytes 240 | Date read 241 | constants.StatusCode 242 | Return value of the library call. 243 | 244 | """ 245 | 246 | try: 247 | sess = self.sessions[session] 248 | except KeyError: 249 | return b"", constants.StatusCode.error_invalid_object 250 | 251 | try: 252 | # We have an explicit except AttributeError 253 | chunk, status = sess.read(count) # type: ignore 254 | if status == constants.StatusCode.error_timeout: 255 | raise errors.VisaIOError(constants.VI_ERROR_TMO) 256 | return chunk, status 257 | except AttributeError: 258 | return b"", constants.StatusCode.error_nonsupported_operation 259 | 260 | def write( 261 | self, session: VISASession, data: bytes 262 | ) -> Tuple[int, constants.StatusCode]: 263 | """Writes data to device or interface synchronously. 264 | 265 | Corresponds to viWrite function of the VISA library. 266 | 267 | Parameters 268 | ---------- 269 | session : VISASession 270 | Unique logical identifier to a session. 271 | data : bytes 272 | Data to be written. 273 | 274 | Returns 275 | ------- 276 | int 277 | Number of bytes actually transferred 278 | constants.StatusCode 279 | Return value of the library call. 280 | 281 | """ 282 | 283 | try: 284 | sess = self.sessions[session] 285 | except KeyError: 286 | return 0, constants.StatusCode.error_invalid_object 287 | 288 | try: 289 | # We have an explicit except AttributeError 290 | return sess.write(data) # type: ignore 291 | except AttributeError: 292 | return 0, constants.StatusCode.error_nonsupported_operation 293 | 294 | def get_attribute( 295 | self, 296 | session: Union[VISASession, VISARMSession, VISAEventContext], 297 | attribute: Union[constants.ResourceAttribute, constants.EventAttribute], 298 | ) -> Tuple[Any, constants.StatusCode]: 299 | """Retrieves the state of an attribute. 300 | 301 | Corresponds to viGetAttribute function of the VISA library. 302 | 303 | Parameters 304 | ---------- 305 | session : Union[VISASession, VISARMSession, VISAEventContext] 306 | Unique logical identifier to a session, event, or find list. 307 | attribute : Union[constants.ResourceAttribute, constants.EventAttribute] 308 | Resource attribute for which the state query is made (see Attributes.*) 309 | 310 | Returns 311 | ------- 312 | Any 313 | State of the queried attribute for a specified resource 314 | constants.StatusCode 315 | Return value of the library call. 316 | 317 | """ 318 | try: 319 | sess = self.sessions[session] # type: ignore 320 | except KeyError: 321 | return 0, constants.StatusCode.error_invalid_object 322 | 323 | # Not sure how to handle events yet and I do not want to error if people keep 324 | # using the bare attribute values. 325 | return sess.get_attribute(attribute) # type: ignore 326 | 327 | def set_attribute( 328 | self, 329 | session: Union[VISASession, VISARMSession, VISAEventContext], 330 | attribute: Union[constants.ResourceAttribute, constants.EventAttribute], 331 | attribute_state: Any, 332 | ) -> constants.StatusCode: 333 | """Sets the state of an attribute. 334 | 335 | Corresponds to viSetAttribute function of the VISA library. 336 | 337 | Parameters 338 | ---------- 339 | session : Union[VISASession, VISARMSession, VISAEventContext] 340 | Unique logical identifier to a session. 341 | attribute : Union[constants.ResourceAttribute, constants.EventAttribute] 342 | Attribute for which the state is to be modified. (Attributes.*) 343 | attribute_state : Any 344 | The state of the attribute to be set for the specified object. 345 | 346 | Returns 347 | ------- 348 | constants.StatusCode 349 | Return value of the library call. 350 | 351 | """ 352 | try: 353 | sess = self.sessions[session] # type: ignore 354 | except KeyError: 355 | return constants.StatusCode.error_invalid_object 356 | 357 | # Not sure how to handle events yet and I do not want to error if people keep 358 | # using the bare attribute values. 359 | return sess.set_attribute(attribute, attribute_state) # type: ignore 360 | 361 | def disable_event(self, session, event_type, mechanism): 362 | # TODO: implement this for GPIB finalization 363 | pass 364 | 365 | def discard_events(self, session, event_type, mechanism): 366 | # TODO: implement this for GPIB finalization 367 | pass 368 | -------------------------------------------------------------------------------- /pyvisa_sim/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Parser function 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | import importlib.resources 10 | import os 11 | import pathlib 12 | from io import StringIO, open 13 | from traceback import format_exc 14 | from typing import ( 15 | Any, 16 | BinaryIO, 17 | Dict, 18 | Generic, 19 | Literal, 20 | Mapping, 21 | TextIO, 22 | Tuple, 23 | TypeVar, 24 | Union, 25 | ) 26 | 27 | import yaml 28 | 29 | from pyvisa.rname import to_canonical_name 30 | 31 | from .channels import Channels 32 | from .component import Component, NoResponse, Responses 33 | from .devices import Device, Devices 34 | 35 | 36 | def _ver_to_tuple(ver: str) -> Tuple[int, ...]: 37 | return tuple(map(int, (ver.split(".")))) 38 | 39 | 40 | #: Version of the specification 41 | SPEC_VERSION = "1.1" 42 | 43 | SPEC_VERSION_TUPLE = _ver_to_tuple(SPEC_VERSION) 44 | 45 | 46 | # FIXME does not allow to alter an inherited dialogue, property, etc 47 | K = TypeVar("K") 48 | V = TypeVar("V") 49 | 50 | 51 | class SimpleChainmap(Generic[K, V]): 52 | """Combine multiple mappings for sequential lookup.""" 53 | 54 | def __init__(self, *maps: Mapping[K, V]) -> None: 55 | self._maps = maps 56 | 57 | def __getitem__(self, key: K) -> V: 58 | for mapping in self._maps: 59 | try: 60 | return mapping[key] 61 | except KeyError: 62 | pass 63 | raise KeyError(key) 64 | 65 | 66 | def _get_pair(dd: Dict[str, str]) -> Tuple[str, str]: 67 | """Return a pair from a dialogue dictionary.""" 68 | return dd["q"].strip(" "), dd["r"].strip(" ") if "r" in dd else NoResponse # type: ignore[return-value] 69 | 70 | 71 | def _get_triplet( 72 | dd: Dict[str, str], 73 | ) -> Tuple[str, Union[str, Literal[Responses.NO]], Union[str, Literal[Responses.NO]]]: 74 | """Return a triplet from a dialogue dictionary.""" 75 | return ( 76 | dd["q"].strip(" "), 77 | dd["r"].strip(" ") if "r" in dd else NoResponse, 78 | dd["e"].strip(" ") if "e" in dd else NoResponse, 79 | ) 80 | 81 | 82 | def _load(content_or_fp: Union[str, bytes, TextIO, BinaryIO]) -> Dict[str, Any]: 83 | """YAML Parse a file or str and check version.""" 84 | try: 85 | data = yaml.load(content_or_fp, Loader=yaml.loader.BaseLoader) 86 | except Exception as e: 87 | raise type(e)("Malformed yaml file:\n%r" % format_exc()) 88 | 89 | try: 90 | ver = data["spec"] 91 | except Exception as e: 92 | raise ValueError("The file does not specify a spec version") from e 93 | 94 | try: 95 | ver = tuple(map(int, (ver.split(".")))) 96 | except Exception as e: 97 | raise ValueError( 98 | "Invalid spec version format. Expect 'X.Y'" 99 | " (X and Y integers), found %s" % ver 100 | ) from e 101 | 102 | if ver > SPEC_VERSION_TUPLE: 103 | raise ValueError( 104 | "The spec version of the file is " 105 | "%s but the parser is %s. " 106 | "Please update pyvisa-sim." % (ver, SPEC_VERSION) 107 | ) 108 | 109 | return data 110 | 111 | 112 | def parse_resource(name: str) -> Dict[str, Any]: 113 | """Parse a resource file.""" 114 | rbytes = importlib.resources.files("pyvisa_sim").joinpath(name).read_bytes() 115 | 116 | return _load(StringIO(rbytes.decode("utf-8"))) 117 | 118 | 119 | def parse_file(fullpath: Union[str, pathlib.Path]) -> Dict[str, Any]: 120 | """Parse a file.""" 121 | with open(fullpath, encoding="utf-8") as fp: 122 | return _load(fp) 123 | 124 | 125 | def update_component( 126 | name: str, comp: Component, component_dict: Dict[str, Any] 127 | ) -> None: 128 | """Get a component from a component dict.""" 129 | for dia in component_dict.get("dialogues", ()): 130 | try: 131 | comp.add_dialogue(*_get_pair(dia)) 132 | except Exception as e: 133 | msg = "In device %s, malformed dialogue %s\n%r" 134 | raise Exception(msg % (name, dia, e)) 135 | 136 | for prop_name, prop_dict in component_dict.get("properties", {}).items(): 137 | try: 138 | getter = _get_pair(prop_dict["getter"]) if "getter" in prop_dict else None 139 | setter = ( 140 | _get_triplet(prop_dict["setter"]) if "setter" in prop_dict else None 141 | ) 142 | comp.add_property( 143 | prop_name, 144 | prop_dict.get("default", ""), 145 | getter, 146 | setter, 147 | prop_dict.get("specs", {}), 148 | ) 149 | except Exception as e: 150 | msg = "In device %s, malformed property %s\n%r" 151 | raise type(e)(msg % (name, prop_name, format_exc())) 152 | 153 | 154 | def get_bases(definition_dict: Dict[str, Any], loader: "Loader") -> Dict[str, Any]: 155 | """Collect inherited behaviors.""" 156 | bases = definition_dict.get("bases", ()) 157 | if bases: 158 | # FIXME this currently does not work 159 | raise NotImplementedError 160 | bases = ( 161 | loader.get_comp_dict(required_version=SPEC_VERSION_TUPLE[0], **b) # type: ignore 162 | for b in bases 163 | ) 164 | return SimpleChainmap(definition_dict, *bases) 165 | else: 166 | return definition_dict 167 | 168 | 169 | def get_channel( 170 | device: Device, 171 | ch_name: str, 172 | channel_dict: Dict[str, Any], 173 | loader: "Loader", 174 | resource_dict: Dict[str, Any], 175 | ) -> Channels: 176 | """Get a channels from a channels dictionary. 177 | 178 | Parameters 179 | ---------- 180 | device : Device 181 | Device from which to retrieve a channel 182 | ch_name : str 183 | Name of the channel to access 184 | channel_dict : Dict[str, Any] 185 | Definition of the channel. 186 | loader : Loader 187 | Loader containing all the loaded information. 188 | resource_dict : Dict[str, Any] 189 | Dictionary describing the resource to which the device is attached. 190 | 191 | Returns 192 | ------- 193 | Channels: 194 | Channels for the device. 195 | 196 | """ 197 | cd = get_bases(channel_dict, loader) 198 | 199 | r_ids = resource_dict.get("channel_ids", {}).get(ch_name, []) 200 | ids = r_ids if r_ids else channel_dict.get("ids", {}) 201 | 202 | can_select = False if channel_dict.get("can_select") == "False" else True 203 | channels = Channels(device, ids, can_select) 204 | 205 | update_component(ch_name, channels, cd) 206 | 207 | return channels 208 | 209 | 210 | def get_device( 211 | name: str, 212 | device_dict: Dict[str, Any], 213 | loader: "Loader", 214 | resource_dict: Dict[str, str], 215 | ) -> Device: 216 | """Get a device from a device dictionary. 217 | 218 | Parameters 219 | ---------- 220 | name : str 221 | Name identifying the device. 222 | device_dict : Dict[str, Any] 223 | Dictionary describing the device. 224 | loader : Loader 225 | Global loader centralizing all devices information. 226 | resource_dict : Dict[str, str] 227 | Resource information to which the device is attached. 228 | 229 | Returns 230 | ------- 231 | Device 232 | Accessed device 233 | 234 | """ 235 | device = Device(name, device_dict.get("delimiter", ";").encode("utf-8")) 236 | 237 | device_dict = get_bases(device_dict, loader) 238 | 239 | err = device_dict.get("error", {}) 240 | device.add_error_handler(err) 241 | 242 | for itype, eom_dict in device_dict.get("eom", {}).items(): 243 | device.add_eom(itype, *_get_pair(eom_dict)) 244 | 245 | update_component(name, device, device_dict) 246 | 247 | for ch_name, ch_dict in device_dict.get("channels", {}).items(): 248 | device.add_channels( 249 | ch_name, get_channel(device, ch_name, ch_dict, loader, resource_dict) 250 | ) 251 | 252 | return device 253 | 254 | 255 | class Loader: 256 | """Loader handling accessing the definitions in YAML files. 257 | 258 | Parameters 259 | ---------- 260 | filename : Union[str, pathlib.Path] 261 | Path to the file to be loaded on creation. 262 | bundled : bool 263 | Is the file bundled with pyvisa-sim itself. 264 | 265 | """ 266 | 267 | #: Definitions loaded from a YAML file. 268 | data: Dict[str, Any] 269 | 270 | def __init__(self, filename: Union[str, pathlib.Path], bundled: bool): 271 | self._cache = {} 272 | self._filename = filename 273 | self._bundled = bundled 274 | self.data = self._load(filename, bundled, SPEC_VERSION_TUPLE[0]) 275 | 276 | def load( 277 | self, 278 | filename: Union[str, pathlib.Path], 279 | bundled: bool, 280 | parent: Union[str, pathlib.Path, None], 281 | required_version: int, 282 | ): 283 | """Load a new file into the loader. 284 | 285 | Parameters 286 | ---------- 287 | filename : Union[str, pathlib.Path] 288 | Filename of the file to parse or name of the resource. 289 | bundled : bool 290 | Is the definition file bundled in pyvisa-sim. 291 | parent : Union[str, pathlib.Path, None] 292 | Path to directory in which the file can be found. If none the directory 293 | in which the initial file was located. 294 | required_version : int 295 | Major required version. 296 | 297 | """ 298 | if self._bundled and not bundled: 299 | msg = "Only other bundled files can be loaded from bundled files." 300 | raise ValueError(msg) 301 | 302 | if parent is None: 303 | parent = self._filename 304 | 305 | base = os.path.dirname(parent) 306 | 307 | filename = os.path.join(base, filename) 308 | 309 | return self._load(filename, bundled, required_version) 310 | 311 | def get_device_dict( 312 | self, 313 | device: str, 314 | filename: Union[str, pathlib.Path, None], 315 | bundled: bool, 316 | required_version: int, 317 | ): 318 | """Access a device definition. 319 | 320 | Parameters 321 | ---------- 322 | device : str 323 | Name of the device information to access. 324 | filename : Union[str, pathlib.Path] 325 | Filename of the file to parse or name of the resource. 326 | The file must be located in the same directory as the original file. 327 | bundled : bool 328 | Is the definition file bundled in pyvisa-sim. 329 | required_version : int 330 | Major required version. 331 | 332 | """ 333 | if filename is None: 334 | data = self.data 335 | else: 336 | data = self.load(filename, bundled, None, required_version) 337 | 338 | return data["devices"][device] 339 | 340 | # --- Private API 341 | 342 | #: (absolute path / resource name / None, bundled) -> dict 343 | _cache: Dict[Tuple[Union[str, pathlib.Path, None], bool], Dict[str, str]] 344 | 345 | #: Path the first loaded file. 346 | _filename: Union[str, pathlib.Path] 347 | 348 | #: Is the loader working with bundled resources. 349 | _bundled: bool 350 | 351 | def _load( 352 | self, filename: Union[str, pathlib.Path], bundled: bool, required_version: int 353 | ) -> Dict[str, Any]: 354 | """Load a YAML definition file. 355 | 356 | The major version of the definition must match. 357 | 358 | """ 359 | if (filename, bundled) in self._cache: 360 | return self._cache[(filename, bundled)] 361 | 362 | if bundled: 363 | assert isinstance(filename, str) 364 | data = parse_resource(filename) 365 | else: 366 | data = parse_file(filename) 367 | 368 | ver = _ver_to_tuple(data["spec"])[0] 369 | if ver != required_version: 370 | raise ValueError( 371 | "Invalid version in %s (bundled = %s). " 372 | "Expected %s, found %s," % (filename, bundled, required_version, ver) 373 | ) 374 | 375 | self._cache[(filename, bundled)] = data 376 | 377 | return data 378 | 379 | 380 | def get_devices(filename: Union[str, pathlib.Path], bundled: bool) -> Devices: 381 | """Get a Devices object from a file. 382 | 383 | Parameters 384 | ---------- 385 | filename : Union[str, pathlib.Path] 386 | Full path of the file to parse or name of the resource. 387 | bundled : bool 388 | Is the definition file bundled in pyvisa-sim. 389 | 390 | Returns 391 | ------- 392 | Devices 393 | Devices found in the definition file. 394 | 395 | """ 396 | 397 | loader = Loader(filename, bundled) 398 | devices = Devices() 399 | 400 | # Iterate through the resources and generate each individual device 401 | # on demand. 402 | 403 | for resource_name, resource_dict in loader.data.get("resources", {}).items(): 404 | device_name = resource_dict["device"] 405 | 406 | dd = loader.get_device_dict( 407 | device_name, 408 | resource_dict.get("filename", None), 409 | resource_dict.get("bundled", False), 410 | SPEC_VERSION_TUPLE[0], 411 | ) 412 | 413 | devices.add_device( 414 | to_canonical_name(resource_name), 415 | get_device(device_name, dd, loader, resource_dict), 416 | ) 417 | 418 | return devices 419 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/__init__.py: -------------------------------------------------------------------------------- 1 | """Implementation for VISA sessions. 2 | 3 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 4 | :license: MIT, see LICENSE for more details. 5 | 6 | """ 7 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/gpib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """GPIB simulated session. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from pyvisa import constants, rname 10 | 11 | from . import session 12 | 13 | 14 | @session.Session.register(constants.InterfaceType.gpib, "INSTR") 15 | class GPIBInstrumentSession(session.MessageBasedSession): 16 | parsed: rname.GPIBInstr 17 | 18 | def after_parsing(self) -> None: 19 | self.attrs[constants.ResourceAttribute.termchar] = int(self.parsed.board) 20 | self.attrs[constants.ResourceAttribute.gpib_primary_address] = int( 21 | self.parsed.primary_address 22 | ) 23 | self.attrs[constants.ResourceAttribute.gpib_secondary_address] = ( 24 | int(self.parsed.secondary_address) 25 | if self.parsed.secondary_address is not None 26 | else constants.VI_NO_SEC_ADDR 27 | ) 28 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/serial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ASRL (Serial) simulated session class. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from typing import Tuple 10 | 11 | from pyvisa import constants, rname 12 | 13 | from .. import common 14 | from . import session 15 | 16 | 17 | @session.Session.register(constants.InterfaceType.asrl, "INSTR") 18 | class SerialInstrumentSession(session.MessageBasedSession): 19 | parsed: rname.ASRLInstr 20 | 21 | def after_parsing(self) -> None: 22 | self.attrs[constants.ResourceAttribute.interface_number] = int( 23 | self.parsed.board 24 | ) 25 | 26 | def write(self, data: bytes) -> Tuple[int, constants.StatusCode]: 27 | send_end, _ = self.get_attribute(constants.ResourceAttribute.send_end_enabled) 28 | asrl_end, _ = self.get_attribute(constants.ResourceAttribute.asrl_end_out) 29 | data_bits, _ = self.get_attribute(constants.ResourceAttribute.asrl_data_bits) 30 | 31 | end_char, _ = self.get_attribute(constants.ResourceAttribute.termchar) 32 | end_char = common.int_to_byte(end_char) 33 | 34 | len_transferred = len(data) 35 | 36 | if asrl_end == constants.SerialTermination.last_bit: 37 | val = b"".join(common.iter_bytes(data, data_bits, send_end)) 38 | self.device.write(val) 39 | else: 40 | val = b"".join(common.iter_bytes(data, data_bits, send_end=None)) 41 | self.device.write(val) 42 | 43 | if asrl_end == constants.SerialTermination.termination_char: 44 | if send_end: 45 | self.device.write(end_char) 46 | len_transferred += 1 47 | 48 | elif asrl_end == constants.SerialTermination.termination_break: 49 | if send_end: 50 | # ASRL Break 51 | pass 52 | 53 | elif not asrl_end == constants.SerialTermination.none: 54 | raise ValueError("Unknown value for VI_ATTR_ASRL_END_OUT") 55 | 56 | return len_transferred, constants.StatusCode.success 57 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Base session class. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | import time 10 | from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar 11 | 12 | from pyvisa import attributes, constants, rname, typing 13 | 14 | from ..common import int_to_byte, logger 15 | from ..devices import Device 16 | 17 | S = TypeVar("S", bound="Session") 18 | 19 | 20 | class Session: 21 | """A base class for Session objects. 22 | 23 | Just makes sure that common methods are defined and information is stored. 24 | 25 | Parameters 26 | ---------- 27 | resource_manager_session : VISARMSession 28 | The session handle of the parent Resource Manager 29 | resource_name : str 30 | The resource name. 31 | parsed : rname.ResourceName 32 | Parsed resource name (optional). 33 | 34 | """ 35 | 36 | #: Maps (Interface Type, Resource Class) to Python class encapsulating that resource. 37 | #: dict[(Interface Type, Resource Class) , Session] 38 | _session_classes: Dict[Tuple[constants.InterfaceType, str], Type["Session"]] = {} 39 | 40 | #: Session handler for the resource manager. 41 | session_type: Tuple[constants.InterfaceType, str] 42 | 43 | #: Simulated device access by this session 44 | device: Device 45 | 46 | @classmethod 47 | def get_session_class( 48 | cls, interface_type: constants.InterfaceType, resource_class: str 49 | ) -> Type["Session"]: 50 | """Return the session class for a given interface type and resource class. 51 | 52 | Parameters 53 | ---------- 54 | interface_type : constants.InterfaceType 55 | Type of the interface for which we need a Session class. 56 | resource_class : str 57 | Resource class for which we need a Session class. 58 | 59 | Returns 60 | ------- 61 | Type[Session] 62 | Registered session class. 63 | 64 | """ 65 | try: 66 | return cls._session_classes[(interface_type, resource_class)] 67 | except KeyError: 68 | raise ValueError( 69 | "No class registered for %s, %s" % (interface_type, resource_class) 70 | ) 71 | 72 | @classmethod 73 | def register( 74 | cls, interface_type: constants.InterfaceType, resource_class: str 75 | ) -> Callable[[Type[S]], Type[S]]: 76 | """Register a session class for a given interface type and resource class. 77 | 78 | Parameters 79 | ---------- 80 | interface_type : constants.InterfaceType 81 | Type of the interface this session should be used for. 82 | resource_class : str 83 | Resource class for which this session should be used for. 84 | 85 | """ 86 | 87 | def _internal(python_class): 88 | if (interface_type, resource_class) in cls._session_classes: 89 | logger.warning( 90 | "%s is already registered in the ResourceManager. " 91 | "Overwriting with %s" 92 | % ((interface_type, resource_class), python_class) 93 | ) 94 | 95 | python_class.session_type = (interface_type, resource_class) 96 | cls._session_classes[(interface_type, resource_class)] = python_class 97 | return python_class 98 | 99 | return _internal 100 | 101 | def __init__( 102 | self, 103 | resource_manager_session: typing.VISARMSession, 104 | resource_name: str, 105 | parsed: Optional[rname.ResourceName] = None, 106 | ): 107 | if parsed is None: 108 | parsed = rname.parse_resource_name(resource_name) 109 | self.parsed = parsed 110 | self.attrs = { 111 | constants.ResourceAttribute.resource_manager_session: resource_manager_session, 112 | constants.ResourceAttribute.resource_name: str(parsed), 113 | constants.ResourceAttribute.resource_class: parsed.resource_class, 114 | constants.ResourceAttribute.interface_type: parsed.interface_type_const, 115 | } 116 | self.after_parsing() 117 | 118 | def after_parsing(self) -> None: 119 | """Override in derived class to customize the session. 120 | 121 | Executed after the resource name has been parsed and the attr dictionary 122 | has been filled. 123 | 124 | """ 125 | pass 126 | 127 | def get_attribute( 128 | self, attribute: constants.ResourceAttribute 129 | ) -> Tuple[Any, constants.StatusCode]: 130 | """Get an attribute from the session. 131 | 132 | Parameters 133 | ---------- 134 | attribute : constants.ResourceAttribute 135 | Attribute whose value to retrieve. 136 | 137 | Returns 138 | ------- 139 | object 140 | Attribute value. 141 | constants.StatusCode 142 | Status code of the operation execution. 143 | 144 | """ 145 | 146 | # Check that the attribute exists. 147 | try: 148 | attr = attributes.AttributesByID[attribute] 149 | except KeyError: 150 | return 0, constants.StatusCode.error_nonsupported_attribute 151 | 152 | # Check that the attribute is valid for this session type. 153 | if not attr.in_resource(self.session_type): 154 | return 0, constants.StatusCode.error_nonsupported_attribute 155 | 156 | # Check that the attribute is readable. 157 | if not attr.read: 158 | raise Exception("Do not now how to handle write only attributes.") 159 | 160 | # Return the current value of the default according the VISA spec 161 | return ( 162 | self.attrs.setdefault(attribute, attr.default), 163 | constants.StatusCode.success, 164 | ) 165 | 166 | def set_attribute( 167 | self, attribute: constants.ResourceAttribute, attribute_state: Any 168 | ) -> constants.StatusCode: 169 | """Get an attribute from the session. 170 | 171 | Parameters 172 | ---------- 173 | attribute : constants.ResourceAttribute 174 | Attribute whose value to alter. 175 | attribute_state : object 176 | Value to set the attribute to. 177 | 178 | Returns 179 | ------- 180 | constants.StatusCode 181 | Status code describing the operation execution. 182 | 183 | """ 184 | 185 | # Check that the attribute exists. 186 | try: 187 | attr = attributes.AttributesByID[attribute] 188 | except KeyError: 189 | return constants.StatusCode.error_nonsupported_attribute 190 | 191 | # Check that the attribute is valid for this session type. 192 | if not attr.in_resource(self.session_type): 193 | return constants.StatusCode.error_nonsupported_attribute 194 | 195 | # Check that the attribute is writable. 196 | if not attr.write: 197 | return constants.StatusCode.error_attribute_read_only 198 | 199 | try: 200 | self.attrs[attribute] = attribute_state 201 | except ValueError: 202 | return constants.StatusCode.error_nonsupported_attribute_state 203 | 204 | return constants.StatusCode.success 205 | 206 | 207 | class MessageBasedSession(Session): 208 | """Base class for Message-Based sessions that support ``read`` and ``write`` methods.""" 209 | 210 | def read(self, count: int) -> Tuple[bytes, constants.StatusCode]: 211 | timeout, _ = self.get_attribute(constants.ResourceAttribute.timeout_value) 212 | timeout /= 1000 213 | 214 | suppress_end_enabled, _ = self.get_attribute( 215 | constants.ResourceAttribute.suppress_end_enabled 216 | ) 217 | termchar, _ = self.get_attribute(constants.ResourceAttribute.termchar) 218 | termchar = int_to_byte(termchar) 219 | termchar_enabled, _ = self.get_attribute( 220 | constants.ResourceAttribute.termchar_enabled 221 | ) 222 | 223 | interface_type, _ = self.get_attribute( 224 | constants.ResourceAttribute.interface_type 225 | ) 226 | is_asrl = interface_type == constants.InterfaceType.asrl 227 | if is_asrl: 228 | asrl_end_in, _ = self.get_attribute(constants.ResourceAttribute.asrl_end_in) 229 | asrl_last_bit, _ = self.get_attribute( 230 | constants.ResourceAttribute.asrl_data_bits 231 | ) 232 | if asrl_last_bit: 233 | asrl_last_bit_mask = 1 << (asrl_last_bit - 1) 234 | 235 | start = time.monotonic() 236 | 237 | out = bytearray() 238 | 239 | while time.monotonic() - start <= timeout: 240 | last, end_indicator = self.device.read() 241 | 242 | out += last 243 | 244 | # N.B.: References here are to VPP-4.3 rev. 7.2.1 245 | # (https://www.ivifoundation.org/downloads/VISA/vpp43_2024-01-04.pdf). 246 | 247 | is_termchar = last == termchar 248 | 249 | if is_asrl: 250 | end_indicator = False 251 | if asrl_end_in == constants.SerialTermination.none: 252 | # Rule 6.1.6. 253 | end_indicator = False 254 | elif ( 255 | asrl_end_in == constants.SerialTermination.termination_char 256 | and is_termchar 257 | ) or ( 258 | asrl_end_in == constants.SerialTermination.last_bit 259 | and out[-1] & asrl_last_bit_mask 260 | ): 261 | # Rule 6.1.7. 262 | end_indicator = True 263 | 264 | if end_indicator and not suppress_end_enabled: 265 | # Rule 6.1.1, Rule 6.1.4, Observation 6.1.3. 266 | return out, constants.StatusCode.success 267 | elif is_termchar and termchar_enabled: 268 | # Rule 6.1.2, Rule 6.1.5, Observation 6.1.4. 269 | return out, constants.StatusCode.success_termination_character_read 270 | elif len(out) == count: 271 | # Rule 6.1.3. 272 | return out, constants.StatusCode.success_max_count_read 273 | 274 | # Busy-wait only if the device's output buffer was empty. 275 | if not last: 276 | time.sleep(0.01) 277 | else: 278 | return out, constants.StatusCode.error_timeout 279 | 280 | def write(self, data: bytes) -> Tuple[int, constants.StatusCode]: 281 | send_end = self.get_attribute(constants.ResourceAttribute.send_end_enabled) 282 | 283 | self.device.write(data) 284 | 285 | if send_end: 286 | # EOM4882 287 | pass 288 | 289 | return len(data), constants.StatusCode.success 290 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/tcpip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """TCPIP simulated session class. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from pyvisa import constants, rname 10 | 11 | from . import session 12 | 13 | 14 | class BaseTCPIPSession(session.MessageBasedSession): 15 | """Base class for TCPIP sessions.""" 16 | 17 | 18 | @session.Session.register(constants.InterfaceType.tcpip, "INSTR") 19 | class TCPIPInstrumentSession(BaseTCPIPSession): 20 | parsed: rname.TCPIPInstr 21 | 22 | def after_parsing(self) -> None: 23 | self.attrs[constants.ResourceAttribute.interface_number] = int( 24 | self.parsed.board 25 | ) 26 | self.attrs[constants.ResourceAttribute.tcpip_address] = self.parsed.host_address 27 | self.attrs[constants.ResourceAttribute.tcpip_device_name] = ( 28 | self.parsed.lan_device_name 29 | ) 30 | 31 | 32 | @session.Session.register(constants.InterfaceType.tcpip, "SOCKET") 33 | class TCPIPSocketSession(BaseTCPIPSession): 34 | parsed: rname.TCPIPSocket 35 | 36 | def after_parsing(self) -> None: 37 | self.attrs[constants.ResourceAttribute.interface_number] = int( 38 | self.parsed.board 39 | ) 40 | self.attrs[constants.ResourceAttribute.tcpip_address] = self.parsed.host_address 41 | self.attrs[constants.ResourceAttribute.tcpip_port] = int(self.parsed.port) 42 | -------------------------------------------------------------------------------- /pyvisa_sim/sessions/usb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """USB simulated session class. 3 | 4 | :copyright: 2014-2024 by PyVISA-sim Authors, see AUTHORS for more details. 5 | :license: MIT, see LICENSE for more details. 6 | 7 | """ 8 | 9 | from typing import Union 10 | 11 | from pyvisa import constants, rname 12 | 13 | from . import session 14 | 15 | 16 | class BaseUSBSession(session.MessageBasedSession): 17 | parsed: Union[rname.USBInstr, rname.USBRaw] 18 | 19 | def after_parsing(self) -> None: 20 | self.attrs[constants.ResourceAttribute.interface_number] = int( 21 | self.parsed.board 22 | ) 23 | self.attrs[constants.ResourceAttribute.manufacturer_id] = ( 24 | self.parsed.manufacturer_id 25 | ) 26 | self.attrs[constants.ResourceAttribute.model_code] = self.parsed.model_code 27 | self.attrs[constants.ResourceAttribute.usb_serial_number] = ( 28 | self.parsed.serial_number 29 | ) 30 | self.attrs[constants.ResourceAttribute.usb_interface_number] = int( 31 | self.parsed.board 32 | ) 33 | 34 | 35 | @session.Session.register(constants.InterfaceType.usb, "INSTR") 36 | class USBInstrumentSession(BaseUSBSession): 37 | parsed: rname.USBInstr 38 | 39 | 40 | @session.Session.register(constants.InterfaceType.usb, "RAW") 41 | class USBRawSession(BaseUSBSession): 42 | parsed: rname.USBRaw 43 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyvisa/pyvisa-sim/b7e7b3018a9dba17ee2139fc28f33f9b774fc70f/pyvisa_sim/testsuite/__init__.py -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | import pyvisa 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def resource_manager(): 10 | rm = pyvisa.ResourceManager("@sim") 11 | yield rm 12 | rm.close() 13 | 14 | 15 | @pytest.fixture 16 | def channels(): 17 | path = os.path.join(os.path.dirname(__file__), "fixtures", "channels.yaml") 18 | rm = pyvisa.ResourceManager(path + "@sim") 19 | yield rm 20 | rm.close() 21 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyvisa/pyvisa-sim/b7e7b3018a9dba17ee2139fc28f33f9b774fc70f/pyvisa_sim/testsuite/fixtures/__init__.py -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/fixtures/channels.yaml: -------------------------------------------------------------------------------- 1 | spec: "1.1" 2 | devices: 3 | device 1: 4 | eom: 5 | ASRL INSTR: 6 | q: "\r\n" 7 | r: "\n" 8 | USB INSTR: 9 | q: "\n" 10 | r: "\n" 11 | TCPIP INSTR: 12 | q: "\n" 13 | r: "\n" 14 | GPIB INSTR: 15 | q: "\n" 16 | r: "\n" 17 | error: ERROR 18 | dialogues: 19 | - q: "?IDN" 20 | r: "LSG Serial #1234" 21 | properties: 22 | selected_channel: 23 | default: 1 24 | getter: 25 | q: 'I?' 26 | r: '{}' 27 | setter: 28 | q: 'I {}' 29 | channels: 30 | type1: 31 | ids: [1, 2] 32 | can_select: False 33 | properties: 34 | frequency: 35 | default: 1.0 36 | getter: 37 | q: 'F?' 38 | r: '{:.3f}' 39 | setter: 40 | q: 'F {:.3f}' 41 | specs: 42 | type: float 43 | min: 1.0 44 | max: 10.0 45 | 46 | device 2: 47 | eom: 48 | ASRL INSTR: 49 | q: "\r\n" 50 | r: "\n" 51 | USB INSTR: 52 | q: "\n" 53 | r: "\n" 54 | TCPIP INSTR: 55 | q: "\n" 56 | r: "\n" 57 | GPIB INSTR: 58 | q: "\n" 59 | r: "\n" 60 | dialogues: 61 | - q: "*IDN?" 62 | r: "SCPI,MOCK,VERSION_1.0" 63 | channels: 64 | channel: 65 | ids: [1, 2, 3] 66 | can_select: True 67 | properties: 68 | voltage: 69 | default: 1.0 70 | getter: 71 | q: "CH {ch_id}:VOLT:IMM:AMPL?" 72 | r: "{:+.8E}" 73 | setter: 74 | q: "CH {ch_id}:VOLT:IMM:AMPL {:.3f}" 75 | specs: 76 | min: 1 77 | max: 6 78 | type: float 79 | output_enabled: 80 | default: 0 81 | getter: 82 | q: "CH {ch_id}:OUTP?" 83 | r: "{:d}" 84 | setter: 85 | q: "CH {ch_id}:OUTP {:d}" 86 | specs: 87 | valid: [0, 1] 88 | type: int 89 | 90 | 91 | resources: 92 | ASRL1::INSTR: 93 | device: device 1 94 | USB::0x1111::0x2222::0x1234::INSTR: 95 | device: device 1 96 | TCPIP::localhost:1111::INSTR: 97 | device: device 1 98 | GPIB::8::INSTR: 99 | device: device 1 100 | ASRL2::INSTR: 101 | device: device 2 102 | USB::0x1111::0x2222::0x2468::INSTR: 103 | device: device 2 104 | TCPIP::localhost:2222::INSTR: 105 | device: device 2 106 | GPIB::9::INSTR: 107 | device: device 2 108 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/test_all.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import random 4 | 5 | import pytest 6 | 7 | from pyvisa.errors import VisaIOError 8 | 9 | # We must fix the seed in order to have reproducible random numbers when testing RANDOM functionality! 10 | random.seed(42) 11 | 12 | 13 | def assert_instrument_response(device, query, data): 14 | response = device.query(query) 15 | assert response == data, "%s, %r == %r" % (device.resource_name, query, data) 16 | 17 | 18 | def test_list(resource_manager): 19 | assert set(resource_manager.list_resources("?*")) == { 20 | "ASRL1::INSTR", 21 | "ASRL2::INSTR", 22 | "ASRL3::INSTR", 23 | "ASRL4::INSTR", 24 | "ASRL5::INSTR", 25 | "TCPIP0::localhost::inst0::INSTR", 26 | "TCPIP0::localhost::10001::SOCKET", 27 | "TCPIP0::localhost:2222::inst0::INSTR", 28 | "TCPIP0::localhost:3333::inst0::INSTR", 29 | "TCPIP0::localhost:4444::inst0::INSTR", 30 | "TCPIP0::localhost:5555::inst0::INSTR", 31 | "USB0::0x1111::0x2222::0x1234::0::INSTR", 32 | "USB0::0x1111::0x2222::0x2468::0::INSTR", 33 | "USB0::0x1111::0x2222::0x3692::0::INSTR", 34 | "USB0::0x1111::0x2222::0x4444::0::INSTR", 35 | "USB0::0x1111::0x2222::0x5555::0::INSTR", 36 | "USB0::0x1111::0x2222::0x4445::0::RAW", 37 | "GPIB0::4::INSTR", 38 | "GPIB0::5::INSTR", 39 | "GPIB0::8::INSTR", 40 | "GPIB0::9::INSTR", 41 | "GPIB0::10::INSTR", 42 | } 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "resource", 47 | [ 48 | "ASRL1::INSTR", 49 | "GPIB0::8::INSTR", 50 | "TCPIP0::localhost::inst0::INSTR", 51 | "TCPIP0::localhost::10001::SOCKET", 52 | "USB0::0x1111::0x2222::0x1234::0::INSTR", 53 | "USB0::0x1111::0x2222::0x4445::0::RAW", 54 | ], 55 | ) 56 | def test_instruments(resource, resource_manager): 57 | inst = resource_manager.open_resource( 58 | resource, 59 | read_termination="\n", 60 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 61 | ) 62 | 63 | assert_instrument_response(inst, "?IDN", "LSG Serial #1234") 64 | 65 | assert_instrument_response(inst, "?FREQ", "100.00") 66 | assert_instrument_response(inst, "!FREQ 10.3", "OK") 67 | assert_instrument_response(inst, "?FREQ", "10.30") 68 | 69 | assert_instrument_response(inst, "?AMP", "1.00") 70 | assert_instrument_response(inst, "!AMP 3.8", "OK") 71 | assert_instrument_response(inst, "?AMP", "3.80") 72 | 73 | assert_instrument_response(inst, "?OFF", "0.00") 74 | assert_instrument_response(inst, "!OFF 1.2", "OK") 75 | assert_instrument_response(inst, "?OFF", "1.20") 76 | 77 | assert_instrument_response(inst, "?OUT", "0") 78 | assert_instrument_response(inst, "!OUT 1", "OK") 79 | assert_instrument_response(inst, "?OUT", "1") 80 | 81 | assert_instrument_response(inst, "?WVF", "0") 82 | assert_instrument_response(inst, "!WVF 1", "OK") 83 | assert_instrument_response(inst, "?WVF", "1") 84 | 85 | assert_instrument_response(inst, "!CAL", "OK") 86 | 87 | with inst.read_termination_context(""): 88 | assert_instrument_response(inst, "?IDN", "LSG Serial #1234\n") 89 | 90 | # Errors 91 | 92 | assert_instrument_response(inst, "!WVF 23", "ERROR") 93 | assert_instrument_response(inst, "!AMP -1.0", "ERROR") 94 | assert_instrument_response(inst, "!AMP 11.0", "ERROR") 95 | assert_instrument_response(inst, "!FREQ 0.0", "FREQ_ERROR") 96 | assert_instrument_response(inst, "BOGUS_COMMAND", "ERROR") 97 | 98 | inst.close() 99 | 100 | 101 | @pytest.mark.parametrize( 102 | "resource", 103 | [ 104 | "ASRL3::INSTR", 105 | "GPIB0::10::INSTR", 106 | "TCPIP0::localhost:3333::inst0::INSTR", 107 | "USB0::0x1111::0x2222::0x3692::0::INSTR", 108 | ], 109 | ) 110 | def test_instruments_on_invalid_command(resource, resource_manager): 111 | inst = resource_manager.open_resource( 112 | resource, 113 | read_termination="\n", 114 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 115 | ) 116 | 117 | response = inst.query("FAKE_COMMAND") 118 | assert response == "INVALID_COMMAND", "invalid command test - response" 119 | 120 | status_reg = inst.query("*ESR?") 121 | assert int(status_reg) == 32, "invalid command test - status" 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "resource", 126 | [ 127 | "ASRL2::INSTR", 128 | "GPIB0::9::INSTR", 129 | "TCPIP0::localhost:2222::inst0::INSTR", 130 | "USB0::0x1111::0x2222::0x2468::0::INSTR", 131 | ], 132 | ) 133 | def test_instrument_on_invalid_values(resource, resource_manager): 134 | inst = resource_manager.open_resource( 135 | resource, 136 | read_termination="\n", 137 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 138 | ) 139 | 140 | inst.write("FAKE_COMMAND") 141 | status_reg = inst.query("*ESR?") 142 | assert int(status_reg) == 32, "invalid test command" 143 | 144 | inst.write(":VOLT:IMM:AMPL 2.00") 145 | status_reg = inst.query("*ESR?") 146 | assert int(status_reg) == 0 147 | 148 | inst.write(":VOLT:IMM:AMPL 0.5") 149 | status_reg = inst.query("*ESR?") 150 | assert int(status_reg) == 32, "invalid range test - max" 155 | 156 | 157 | @pytest.mark.parametrize( 158 | "resource", 159 | [ 160 | "ASRL3::INSTR", 161 | "GPIB0::10::INSTR", 162 | "TCPIP0::localhost:3333::inst0::INSTR", 163 | "USB0::0x1111::0x2222::0x3692::0::INSTR", 164 | ], 165 | ) 166 | def test_instruments_with_timeouts(resource, resource_manager): 167 | inst = resource_manager.open_resource(resource, timeout=0.1) 168 | 169 | with pytest.raises(VisaIOError): 170 | inst.read() 171 | 172 | 173 | @pytest.mark.parametrize( 174 | "resource", 175 | [ 176 | "ASRL4::INSTR", 177 | "GPIB0::4::INSTR", 178 | "TCPIP0::localhost:4444::inst0::INSTR", 179 | "USB0::0x1111::0x2222::0x4444::0::INSTR", 180 | ], 181 | ) 182 | def test_instrument_for_error_state(resource, resource_manager): 183 | inst = resource_manager.open_resource( 184 | resource, 185 | read_termination="\n", 186 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 187 | ) 188 | 189 | assert_instrument_response(inst, ":SYST:ERR?", "0, No Error") 190 | 191 | inst.write("FAKE COMMAND") 192 | assert_instrument_response(inst, ":SYST:ERR?", "1, Command error") 193 | 194 | inst.write(":VOLT:IMM:AMPL 0") 195 | assert_instrument_response(inst, ":SYST:ERR?", "1, Command error") 196 | 197 | 198 | def test_device_write_logging(caplog, resource_manager) -> None: 199 | instr = resource_manager.open_resource( 200 | "USB0::0x1111::0x2222::0x4444::0::INSTR", 201 | read_termination="\n", 202 | write_termination="\n", 203 | ) 204 | 205 | with caplog.at_level(logging.DEBUG): 206 | instr.write("*IDN?") 207 | instr.read() 208 | 209 | assert "input buffer: b'D'" not in caplog.text 210 | assert r"input buffer: b'*IDN?\n'" in caplog.text 211 | 212 | 213 | @pytest.mark.parametrize( 214 | "resource", 215 | [ 216 | "ASRL5::INSTR", 217 | "GPIB0::5::INSTR", 218 | "TCPIP::localhost:5555::INSTR", 219 | "USB0::0x1111::0x2222::0x5555::INSTR", 220 | ], 221 | ) 222 | def test_multiple_outputs(resource, resource_manager): 223 | # Re-initialization is needed also for each resource. 224 | random.seed(42) 225 | 226 | inst = resource_manager.open_resource( 227 | resource, 228 | read_termination="\n", 229 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 230 | ) 231 | 232 | assert_instrument_response(inst, ":READ?", "7.79") 233 | assert_instrument_response(inst, ":SCAN?", "2.57, 1.47, 1.08, 7.78, 5.73") 234 | assert_instrument_response(inst, ":VOLT:IMM:AMPL?", "0.90") 235 | 236 | # Errors 237 | with pytest.raises(Exception): 238 | assert_instrument_response(inst, ":BAD:SCAN:OUTSIDE?", "") 239 | with pytest.raises(Exception): 240 | assert_instrument_response(inst, ":BAD:SCAN:INSIDE?", "") 241 | 242 | inst.close() 243 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/test_channel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | 5 | def assert_instrument_response(device, query, data): 6 | response = device.query(query) 7 | assert response == data, "%s, %r == %r" % (device.resource_name, query, data) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "resource", 12 | [ 13 | "ASRL1::INSTR", 14 | "GPIB0::8::INSTR", 15 | "TCPIP0::localhost:1111::inst0::INSTR", 16 | "USB0::0x1111::0x2222::0x1234::0::INSTR", 17 | ], 18 | ) 19 | def test_instrument_with_channel_preselection(resource, channels): 20 | inst = channels.open_resource( 21 | resource, 22 | read_termination="\n", 23 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 24 | ) 25 | 26 | assert_instrument_response(inst, "I?", "1") 27 | assert_instrument_response(inst, "F?", "1.000") 28 | 29 | inst.write("F 5.0") 30 | assert_instrument_response(inst, "F?", "5.000") 31 | assert_instrument_response(inst, "I 2;F?", "1.000") 32 | 33 | inst.close() 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "resource", 38 | [ 39 | "ASRL2::INSTR", 40 | "GPIB0::9::INSTR", 41 | "TCPIP0::localhost:2222::inst0::INSTR", 42 | "USB0::0x1111::0x2222::0x2468::0::INSTR", 43 | ], 44 | ) 45 | def test_instrument_with_inline_selection(resource, channels): 46 | inst = channels.open_resource( 47 | resource, 48 | read_termination="\n", 49 | write_termination="\r\n" if resource.startswith("ASRL") else "\n", 50 | ) 51 | 52 | assert_instrument_response(inst, "CH 1:VOLT:IMM:AMPL?", "+1.00000000E+00") 53 | 54 | inst.write("CH 1:VOLT:IMM:AMPL 2.0") 55 | assert_instrument_response(inst, "CH 1:VOLT:IMM:AMPL?", "+2.00000000E+00") 56 | assert_instrument_response(inst, "CH 2:VOLT:IMM:AMPL?", "+1.00000000E+00") 57 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/test_common.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import pytest 4 | 5 | from pyvisa_sim import common 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "bits, want", 10 | [ 11 | (0, 0b0), 12 | (1, 0b1), 13 | (5, 0b0001_1111), 14 | (7, 0b0111_1111), 15 | (8, 0b1111_1111), 16 | (11, 0b0111_1111_1111), 17 | ], 18 | ) 19 | def test_create_bitmask(bits, want): 20 | got = common._create_bitmask(bits) 21 | assert got == want 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "data, data_bits, send_end, want", 26 | [ 27 | (b"\x01", None, False, b"\x01"), 28 | (b"hello world!", None, False, b"hello world!"), 29 | # Only apply the mask 30 | (b"\x03", 2, None, b"\x03"), # 0b0000_0011 --> 0b0000_0011 31 | (b"\x04", 2, None, b"\x00"), # 0b0000_0100 --> 0b0000_0000 32 | (b"\xff", 5, None, b"\x1f"), # 0b1111_1111 --> 0b0001_1111 33 | (b"\xfe", 7, None, b"\x7e"), # 0b1111_1110 --> 0b0111_1110 34 | (b"\xfe", 8, None, b"\xfe"), # 0b1111_1110 --> 0b1111_1110 35 | (b"\xff", 9, None, b"\xff"), # 0b1111_1111 --> 0b1111_1111 36 | # Always set highest bit *of data_bits* to 0 37 | (b"\x04", 2, False, b"\x00"), # 0b0000_0100 --> 0b0000_0000 38 | (b"\x04", 3, False, b"\x00"), # 0b0000_0100 --> 0b0000_0000 39 | (b"\x05", 3, False, b"\x01"), # 0b0000_0101 --> 0b0000_0001 40 | (b"\xff", 7, False, b"\x3f"), # 0b1111_1111 --> 0b0011_1111 41 | (b"\xff", 8, False, b"\x7f"), # 0b1111_1111 --> 0b0111_1111 42 | # Always set highest bit *of data_bits* to 1 43 | (b"\x04", 2, True, b"\x02"), # 0b0000_0100 --> 0b0000_0010 44 | (b"\x04", 3, True, b"\x04"), # 0b0000_0100 --> 0b0000_0100 45 | (b"\x01", 3, True, b"\x05"), # 0b0000_0001 --> 0b0000_0101 46 | (b"\x9f", 7, True, b"\x5f"), # 0b1001_1111 --> 0b0101_1111 47 | (b"\x9f", 8, True, b"\x9f"), # 0b1001_1111 --> 0b1001_1111 48 | # data_bits >8 bits act like data_bits=8, as type(data) is "bytes" 49 | # which is limited 8 bits per character. 50 | (b"\xff", 9, None, b"\xff"), 51 | (b"\xff", 9, False, b"\x7f"), 52 | (b"\xff", 9, True, b"\xff"), 53 | # send_end=None only applies the mask everywhere and doesn't touch the 54 | # highest bit 55 | # 0x6d: 0b0110_1101 (m) --> 0x0d: 0b0000_1101 (\r) 56 | # 0x5e: 0b0101_1110 (^) --> 0x0e: 0b0000_1110 57 | # 0x25: 0b0010_0101 (%) --> 0x05: 0b0000_0101 58 | # 0x25: 0b0010_0101 (%) --> 0x05: 0b0000_0101 59 | (b"\x6d\x5e\x25\x25", 4, None, b"\r\x0e\x05\x05"), 60 | # send_end=False sets highest post-mask bit to 0 for all 61 | # 0x6d: 0b0110_1101 (m) --> 0x05: 0b0000_0101 62 | # 0x5e: 0b0101_1110 (^) --> 0x06: 0b0000_0110 63 | # 0x25: 0b0010_0101 (%) --> 0x05: 0b0000_0101 64 | # 0x25: 0b0010_0101 (%) --> 0x05: 0b0000_0101 65 | (b"\x6d\x5e\x25\x25", 4, False, b"\x05\x06\x05\x05"), 66 | # send_end=True sets highest bit to 0 except for final byte 67 | # 0x6d: 0b0110_1101 (m) --> 0x05: 0b0000_0101 68 | # 0x5e: 0b0101_1110 (^) --> 0x06: 0b0000_0110 69 | # 0x25: 0b0010_0101 (%) --> 0x05: 0b0000_0101 70 | # 0x25: 0b0010_0101 (%) --> 0x0d: 0b0000_1101 71 | (b"\x6d\x5e\x25\x25", 4, True, b"\x05\x06\x05\x0d"), 72 | # 0x61: 0b0110_0001 (a) --> 0x21: 0b0010_0001 (!) 73 | # 0xb1: 0b1011_0001 (±) --> 0x31: 0b0011_0001 (1) 74 | (b"a\xb1", 6, None, b"\x21\x31"), 75 | # 0x61: 0b0110_0001 (a) --> 0x01: 0b0000_0001 76 | # 0xb1: 0b1011_0001 (±) --> 0x11: 0b0001_0001 77 | (b"a\xb1", 6, False, b"\x01\x11"), 78 | # 0x61: 0b0110_0001 (a) --> 0x01: 0b0000_0001 79 | # 0xb1: 0b1011_0001 (±) --> 0x31: 0b0011_0001 (1) 80 | (b"a\xb1", 6, True, b"\x011"), 81 | ], 82 | ) 83 | def test_iter_bytes( 84 | data: bytes, data_bits: Optional[int], send_end: bool, want: List[bytes] 85 | ) -> None: 86 | got = b"".join(common.iter_bytes(data, data_bits=data_bits, send_end=send_end)) 87 | assert got == want 88 | 89 | 90 | def test_iter_bytes_with_send_end_requires_data_bits() -> None: 91 | with pytest.raises(ValueError): 92 | # Need to wrap in list otherwise the iterator is never called. 93 | list(common.iter_bytes(b"", data_bits=None, send_end=True)) 94 | 95 | 96 | def test_iter_bytes_raises_on_bad_data_bits() -> None: 97 | with pytest.raises(ValueError): 98 | list(common.iter_bytes(b"", data_bits=0, send_end=None)) 99 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/test_parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Dict, Tuple 3 | 4 | import pytest 5 | 6 | from pyvisa_sim import parser 7 | from pyvisa_sim.component import NoResponse, OptionalStr 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "dialogue_dict, want", 12 | [ 13 | ({"q": "foo", "r": "bar"}, ("foo", "bar")), 14 | ({"q": "foo ", "r": " bar"}, ("foo", "bar")), 15 | ({"q": " foo", "r": "bar "}, ("foo", "bar")), 16 | ({"q": " foo ", "r": " bar "}, ("foo", "bar")), 17 | # Make sure to support queries that don't have responses 18 | ({"q": "foo"}, ("foo", NoResponse)), 19 | # Ignore other keys 20 | ({"q": "foo", "bar": "bar"}, ("foo", NoResponse)), 21 | ], 22 | ) 23 | def test_get_pair(dialogue_dict: Dict[str, str], want: Tuple[str, OptionalStr]) -> None: 24 | got = parser._get_pair(dialogue_dict) 25 | assert got == want 26 | 27 | 28 | def test_get_pair_requires_query_key() -> None: 29 | with pytest.raises(KeyError): 30 | parser._get_pair({"r": "bar"}) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "dialogue_dict, want", 35 | [ 36 | ({"q": "foo", "r": "bar", "e": "baz"}, ("foo", "bar", "baz")), 37 | ({"q": "foo ", "r": " bar", "e": " baz "}, ("foo", "bar", "baz")), 38 | ({"q": " foo", "r": "bar ", "e": "baz "}, ("foo", "bar", "baz")), 39 | ({"q": " foo ", "r": " bar ", "e": " baz"}, ("foo", "bar", "baz")), 40 | # Make sure to support queries that don't have responses 41 | ({"q": "foo"}, ("foo", NoResponse, NoResponse)), 42 | ({"q": "foo", "r": "bar"}, ("foo", "bar", NoResponse)), 43 | ({"q": "foo", "e": "bar"}, ("foo", NoResponse, "bar")), 44 | # Ignore other keys 45 | ({"q": "foo", "bar": "bar"}, ("foo", NoResponse, NoResponse)), 46 | ], 47 | ) 48 | def test_get_triplet( 49 | dialogue_dict: Dict[str, str], want: Tuple[str, OptionalStr, OptionalStr] 50 | ) -> None: 51 | got = parser._get_triplet(dialogue_dict) 52 | assert got == want 53 | 54 | 55 | def test_get_triplet_requires_query_key() -> None: 56 | with pytest.raises(KeyError): 57 | parser._get_triplet({"r": "bar"}) 58 | -------------------------------------------------------------------------------- /pyvisa_sim/testsuite/test_serial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pyvisa 3 | from pyvisa_sim.sessions import serial 4 | 5 | serial.SerialInstrumentSession 6 | 7 | 8 | def test_serial_write_with_termination_last_bit(resource_manager): 9 | instr = resource_manager.open_resource( 10 | "ASRL4::INSTR", 11 | read_termination="\n", 12 | write_termination="\r\n", 13 | ) 14 | 15 | # Ensure that we test the `asrl_end` block of serial.SerialInstrumentSession.write 16 | instr.set_visa_attribute( 17 | pyvisa.constants.ResourceAttribute.asrl_end_out, 18 | pyvisa.constants.SerialTermination.last_bit, 19 | ) 20 | 21 | instr.set_visa_attribute( 22 | pyvisa.constants.ResourceAttribute.send_end_enabled, 23 | pyvisa.constants.VI_FALSE, 24 | ) 25 | 26 | instr.write("*IDN?") 27 | assert instr.read() == "SCPI,MOCK,VERSION_1.0" 28 | --------------------------------------------------------------------------------