├── .github ├── CODEOWNERS ├── workflows │ ├── semilinear_history.yml │ ├── tox.yml │ ├── check_version.yml │ └── publish.yml ├── check_version_uniqueness.py ├── compare_ruff_output.py └── Check-SemilinearHistory.ps1 ├── tests ├── test_import.py ├── test_config.py ├── test_utils.py ├── test_device_manager.py └── test_empty_device.py ├── LICENSE.md ├── src └── pysweepme │ ├── Architecture.py │ ├── _utils.py │ ├── __init__.py │ ├── pysweepme_types.py │ ├── ErrorMessage.py │ ├── DeviceManager.py │ ├── UserInterface.py │ ├── Config.py │ ├── WinFolder.py │ ├── PortManager.py │ ├── FolderManager.py │ ├── EmptyDeviceClass.py │ └── Ports.py ├── tox.ini ├── pyproject.toml └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @SweepMe/pysweepme-owners 2 | -------------------------------------------------------------------------------- /.github/workflows/semilinear_history.yml: -------------------------------------------------------------------------------- 1 | name: Check Semilinear History 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - '1.5.6' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | semilinear_history: 14 | name: Check Semilinear History 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Check Semilinear History 19 | shell: pwsh 20 | run: | 21 | .\.github\Check-SemilinearHistory.ps1 -targetBranch ${{ github.base_ref }} -sourceBranch ${{ github.head_ref }} 22 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Run Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - '1.5.6' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | checks: 14 | name: Run Checks 15 | runs-on: windows-2022 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.9" 23 | - name: Install tox 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install tox~=4.6.1 27 | - name: Run tox 28 | run: | 29 | tox run-parallel --parallel-no-spinner 30 | -------------------------------------------------------------------------------- /.github/workflows/check_version.yml: -------------------------------------------------------------------------------- 1 | name: Check Version 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - '1.5.6' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | check_version: 14 | name: Check Version 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 3.9 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.9" 22 | - name: Install environment 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install --no-deps . 26 | - name: Run version check 27 | run: | 28 | python .github/check_version_uniqueness.py 29 | 30 | -------------------------------------------------------------------------------- /.github/check_version_uniqueness.py: -------------------------------------------------------------------------------- 1 | """Make sure that the version number has been increased and does not exist on PyPI yet.""" 2 | 3 | import importlib.metadata 4 | import subprocess 5 | 6 | pysweepme_version = importlib.metadata.version("pysweepme") 7 | 8 | not_found = False 9 | 10 | try: 11 | subprocess.check_call( 12 | [ # noqa: S603, S607 13 | "python", 14 | "-m", 15 | "pip", 16 | "install", 17 | "--no-deps", 18 | "--ignore-installed", 19 | "--dry-run", 20 | f"pysweepme=={pysweepme_version}", 21 | ], 22 | ) 23 | except subprocess.SubprocessError: 24 | not_found = True 25 | 26 | if not_found is False: 27 | exc_msg = ( 28 | f"Version {pysweepme_version} seems to be published already. " 29 | f"Did you forget to increase the version number in pysweepme/__init__.py?" 30 | ) 31 | print(f"::error::{exc_msg}") # noqa: T201 32 | raise ValueError(exc_msg) 33 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | """Verify that pysweepme import does not already initialize instances.""" 2 | 3 | import sys 4 | 5 | 6 | def test_import() -> None: 7 | """Verify that pysweepme import does not have side-effects. 8 | 9 | When importing pysweepme, it often happened that pysweepme itself already initializes e.g. the FolderManager. 10 | Then it is no longer possible to define a multi-instance ID that modifies certain folders. 11 | This test shall make sure, that an import of pysweepme does not initialize the FolderManager yet. 12 | """ 13 | # the test only works if pysweepme was not imported before 14 | assert "pysweepme" not in sys.modules, "pysweepme was already imported before running the test" 15 | import pysweepme 16 | 17 | assert "pysweepme" in sys.modules, "pysweepme could not be imported successfully" 18 | assert ( 19 | pysweepme.FolderManager.FolderManager.has_instance() is False 20 | ), "Import of pysweepme has already initialized the FolderManager, preventing multi-instance mode." 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2025 SweepMe! GmbH (sweep-me.net) 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 | 23 | The WinFolder package has a separate license not covered in this document and can be found in the header of the WinFolder.py file. 24 | 25 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Test functions for the Config module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from typing import cast 7 | 8 | from pysweepme.Config import Config, DefaultFileIO 9 | from pysweepme.pysweepme_types import FileIOProtocol 10 | 11 | 12 | class MyPath: 13 | """An alternative class for testing that is compatible to Path.""" 14 | 15 | 16 | class TestDefaultFileIO: 17 | """Tests for checking if the default and registering of custom pathlib.Path()-like classes works.""" 18 | 19 | def test_default_behaviour(self) -> None: 20 | """Test if the standard pathlib.Path() is used without a registered alternative.""" 21 | # make sure there is no custom registered function 22 | DefaultFileIO.custom_default_file_io = None 23 | path = DefaultFileIO.default_fileio(".") 24 | assert isinstance(path, Path) 25 | assert not isinstance(path, MyPath) 26 | 27 | def test_alternative_default(self) -> None: 28 | """Test if registering an alternative default for file IO operations works.""" 29 | 30 | def get_mypath(_: Path | str) -> FileIOProtocol: 31 | return cast(FileIOProtocol, MyPath()) 32 | 33 | DefaultFileIO.register_custom_default(get_mypath) 34 | path = DefaultFileIO.default_fileio(".") 35 | assert isinstance(path, MyPath) 36 | 37 | def test_config_init(self) -> None: 38 | """Make sure that the Config is using the registered file IO type from the DefaultFileIO class.""" 39 | my_path = cast(FileIOProtocol, MyPath()) 40 | 41 | def get_mypath(_: Path | str) -> FileIOProtocol: 42 | return my_path 43 | 44 | DefaultFileIO.register_custom_default(get_mypath) 45 | config = Config(".") 46 | assert config.reader_writer is my_path 47 | -------------------------------------------------------------------------------- /src/pysweepme/Architecture.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List 3 | 4 | 5 | class VersionInfo: 6 | _information_extracted: bool = False 7 | _python_version_str: str 8 | _python_version_short_str: str 9 | _python_bitness_str: str 10 | _python_suffix: str 11 | _python_compatibility_flags: List[str] 12 | 13 | def extract_information(self): 14 | if not self._information_extracted: 15 | version = sys.version_info 16 | self._python_version_str = f"{version.major}.{version.minor}" 17 | self._python_version_short_str = f"{version.major}{version.minor}" 18 | self._python_bitness_str = "64" if sys.maxsize > 0x100000000 else "32" 19 | self._python_suffix = f"{self._python_version_short_str}_{self._python_bitness_str}" 20 | self._python_compatibility_flags = [ 21 | "any", 22 | f"any-{self._python_bitness_str}", 23 | f"{self._python_version_str}-any", 24 | f"{self._python_version_str}-{self._python_bitness_str}", 25 | ] 26 | self._information_extracted = True 27 | 28 | @property 29 | def python_version_str(self) -> str: 30 | if not self._information_extracted: 31 | self.extract_information() 32 | return self._python_version_str 33 | 34 | @property 35 | def python_bitness_str(self) -> str: 36 | if not self._information_extracted: 37 | self.extract_information() 38 | return self._python_bitness_str 39 | 40 | @property 41 | def python_suffix(self) -> str: 42 | if not self._information_extracted: 43 | self.extract_information() 44 | return self._python_suffix 45 | 46 | @property 47 | def python_compatibility_flags(self) -> List[str]: 48 | if not self._information_extracted: 49 | self.extract_information() 50 | return self._python_compatibility_flags 51 | 52 | 53 | version_info = VersionInfo() 54 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox ~= 4.6 4 | env_list = ruffregression, ruff, mypy, pytest, build 5 | 6 | [testenv:ruff] 7 | description = run ruff linting 8 | basepython = py39 9 | deps = 10 | ruff ~= 0.0.272 11 | extras = typed 12 | commands = ruff --ignore=C90,I,N,D,UP,ANN,BLE,A,COM,EM,PIE,T20,Q,RET,SIM,PTH,TD,ERA,PL,TRY,RUF,W291,W293,E722,ARG002,ARG003,E501,E713,B006,E714,E402,S110,S112,B904,SLF001,F401,B018 . 13 | 14 | [testenv:black] 15 | description = run black formatter 16 | basepython = py39 17 | deps = 18 | black ~= 23.3.0 19 | commands = black --check . 20 | 21 | [testenv:mypy] 22 | description = run mypy type checking 23 | basepython = py39 24 | deps = 25 | mypy ~= 1.3.0 26 | pytest ~= 7.3.2 27 | extras = typed 28 | commands = mypy --disable-error-code=no-untyped-call --disable-error-code=no-untyped-def . 29 | 30 | [testenv:pytest] 31 | description = run unit tests 32 | basepython = py39 33 | deps = 34 | pytest ~= 7.3.2 35 | # test_import.py must be tested separately, as it requires to import pysweepme at the exact moment 36 | commands = 37 | pytest tests/test_import.py 38 | pytest tests --ignore tests/test_import.py 39 | 40 | [testenv:ruffregression] 41 | description = compare strict ruff failures to master 42 | basepython = py39 43 | deps = 44 | ruff ~= 0.0.272 45 | allowlist_externals = git, cmd 46 | extras = typed 47 | commands = 48 | cmd /c "(if exist .tox\reference rmdir /S /Q .tox\reference) && mkdir .tox\reference" 49 | git fetch origin main 50 | git --work-tree=.tox/reference restore --source=origin/main -- . 51 | cmd /c "cd .tox\reference && python -m venv .venv && .venv\Scripts\activate.bat && python -m pip install -e .[dev,typed]" 52 | cmd /c "copy pyproject.toml .tox\reference\pyproject.toml" 53 | cmd /c "cd .tox\reference && .venv\Scripts\activate.bat && ruff --format=json . > ..\..\ref.json & exit 0" 54 | cmd /c "ruff --format=json . > ruff.json & exit 0" 55 | python .github\compare_ruff_output.py 56 | 57 | [testenv:build] 58 | description = create pysweepme wheel 59 | basepython = py39 60 | skip_install = True 61 | deps = 62 | build ~= 0.10.0 63 | commands = python -m build --wheel -------------------------------------------------------------------------------- /src/pysweepme/_utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import re 4 | from itertools import zip_longest 5 | from typing import Any, Callable 6 | 7 | from . import __version__ 8 | from .ErrorMessage import debug 9 | 10 | 11 | def _get_pysweepme_version_tuple(version: str) -> tuple[int, ...]: 12 | version_extract = re.search(r"^(?:\d+\.)*\d+", version) 13 | if not version_extract: 14 | msg = f"Cannot extract version from {version}" 15 | raise ValueError(msg) 16 | return tuple(map(int, version_extract.group(0).split("."))) 17 | 18 | 19 | _pysweepme_version = _get_pysweepme_version_tuple(__version__) 20 | 21 | 22 | def _is_version_reached(version: str) -> bool: 23 | version_tuple = tuple(map(int, version.split("."))) 24 | # zip and un-zip the version tuples to make them same length 25 | version_tuple, compare = zip(*zip_longest(version_tuple, _pysweepme_version, fillvalue=0)) 26 | if version_tuple > compare: 27 | return False 28 | return True 29 | 30 | 31 | def deprecated( 32 | removed_in: str, 33 | instructions: str, 34 | name: str = "", 35 | blame_call: bool = True, 36 | ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 37 | removed_phrase = "in the next release" if _is_version_reached(removed_in) else f"in version {removed_in}" 38 | 39 | def decorator(func: Callable[..., Any]) -> Callable[..., Any]: 40 | if callable(func): 41 | 42 | def get_call_blame() -> str: 43 | try: 44 | frame = inspect.stack()[2] # 0 is get_call_blame(), 1 is _inner() 45 | file = frame.filename 46 | code = (frame.code_context or [""])[0].strip() 47 | line = frame.lineno 48 | blame = f" ['{code.strip()}' in '{file}', line {line}]" 49 | except (TypeError, OSError): 50 | blame = "" 51 | return blame 52 | 53 | @functools.wraps(func) 54 | def _inner(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 55 | blame_call_str = get_call_blame() if blame_call else "" 56 | debug( 57 | f"{name or func.__name__}() is deprecated and will be removed {removed_phrase}. " 58 | f"{instructions}{blame_call_str}", 59 | ) 60 | return func(*args, **kwargs) 61 | 62 | return _inner 63 | msg = f"{func!s} is not callable and can't be decorated." 64 | raise NotImplementedError(msg) 65 | 66 | return decorator 67 | -------------------------------------------------------------------------------- /src/pysweepme/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | 23 | 24 | __version__ = "1.5.7.5" 25 | 26 | import sys 27 | 28 | from . import FolderManager 29 | from . import EmptyDeviceClass 30 | from . import DeviceManager 31 | from . import PortManager 32 | from . import Config 33 | from . import Ports 34 | from . import ErrorMessage 35 | 36 | if sys.platform == "win32": 37 | from . import WinFolder 38 | 39 | sys.modules["FolderManager"] = sys.modules["pysweepme.FolderManager"] 40 | sys.modules["EmptyDeviceClass"] = sys.modules["pysweepme.EmptyDeviceClass"] 41 | sys.modules["DeviceManager"] = sys.modules["pysweepme.DeviceManager"] 42 | sys.modules["Ports"] = sys.modules["pysweepme.Ports"] 43 | sys.modules["ErrorMessage"] = sys.modules["pysweepme.ErrorMessage"] 44 | if sys.platform == "win32": 45 | sys.modules["WinFolder"] = sys.modules["pysweepme.WinFolder"] 46 | 47 | from .FolderManager import addFolderToPATH, get_path, set_path 48 | from .EmptyDeviceClass import EmptyDevice 49 | from .DeviceManager import get_device, get_driver 50 | from .Ports import get_port, close_port 51 | from .ErrorMessage import error, debug 52 | 53 | __all__ = [ 54 | "FolderManager", 55 | "addFolderToPATH", 56 | "get_path", 57 | "set_path", 58 | "EmptyDeviceClass", 59 | "EmptyDevice", 60 | "DeviceManager", 61 | "get_device", 62 | "get_driver", 63 | "Ports", 64 | "get_port", 65 | "close_port", 66 | "PortManager", 67 | "Config", 68 | "ErrorMessage", 69 | "error", 70 | "debug", 71 | "WinFolder", 72 | ] 73 | -------------------------------------------------------------------------------- /.github/compare_ruff_output.py: -------------------------------------------------------------------------------- 1 | """Compare ruff output and complain when number of violations increases.""" 2 | 3 | import json 4 | import logging 5 | from pathlib import Path 6 | 7 | ParsedResult = dict[tuple[str, str], tuple[int, str]] 8 | Comparison = dict[tuple[str, str], tuple[int, int, str]] 9 | 10 | logging.basicConfig(format="%(levelname)s: %(message)s") 11 | 12 | 13 | def parse_results(filename: Path) -> ParsedResult: 14 | """Read json and generate statistics.""" 15 | parsed_results: ParsedResult = {} 16 | for violation in json.loads(filename.read_text()): 17 | key = (violation["filename"].replace(r"\.tox\reference", ""), violation["code"]) 18 | count = parsed_results.get(key, (0, ""))[0] + 1 19 | parsed_results[key] = (count, violation["message"]) 20 | return parsed_results 21 | 22 | 23 | def compare_results(*, result: ParsedResult, reference: ParsedResult) -> Comparison: 24 | """Compare two ruff results and generate a report. 25 | 26 | Read two results from ruff, one from the actual code and one from the reference and compare them. 27 | If there is any violation on a per file and per rule basis where the number of violations increased, 28 | this will be part of the return value. 29 | 30 | 31 | Returns: 32 | A dictionary mapping from the key which is a tuple of filename and rule code to a tuple consisting of 33 | the reference violations, the actual violations, and the rule message. 34 | """ 35 | comparison: Comparison = {} 36 | for key, violation_details in result.items(): 37 | violations = violation_details[0] 38 | reference_violations = reference.get(key, (0, ""))[0] 39 | message = violation_details[1] 40 | if violations > reference_violations: 41 | comparison[key] = (reference_violations, violations, message) 42 | return comparison 43 | 44 | 45 | def output_violations(comparison: Comparison) -> bool: 46 | """Print output and return if everything is ok.""" 47 | for key, violation_details in comparison.items(): 48 | logging.error( 49 | f"Violations increased from {violation_details[0]} to {violation_details[1]} " 50 | f"for rule {key[1]} [{violation_details[2]}] in file '{key[0]}'.", 51 | ) 52 | if len(comparison) > 0: 53 | return False 54 | return True 55 | 56 | 57 | result = parse_results(Path("ruff.json")) 58 | reference = parse_results(Path("ref.json")) 59 | 60 | comparison = compare_results(result=result, reference=reference) 61 | if output_violations(comparison) is False: 62 | exc_msg = {"Code quality regression detected."} 63 | print(f"::error::{exc_msg}") # noqa: T201 64 | raise ValueError(exc_msg) 65 | -------------------------------------------------------------------------------- /.github/Check-SemilinearHistory.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [Parameter(Mandatory)] 3 | [string]$targetBranch, 4 | [Parameter(Mandatory)] 5 | [string]$sourceBranch 6 | ) 7 | 8 | $ErrorActionPreference = 'Stop' 9 | 10 | $versionBranches = @( 11 | "main", 12 | "1.5.6", 13 | "v1.5.5.x" 14 | ) 15 | 16 | git fetch origin $targetBranch 17 | $commitTarget = git rev-parse "origin/$targetBranch" 18 | git fetch origin $sourceBranch 19 | $commitSource = git rev-parse "origin/$sourceBranch" 20 | 21 | # verify that source branch originates from the latest commit of the target branch 22 | # (i.e. a fast-forward merge could be performed) 23 | git merge-base --is-ancestor $commitTarget $commitSource 24 | if ($LASTEXITCODE -ne "0") 25 | { 26 | throw "Merge would create a non-semilinear history." 27 | } 28 | 29 | # the target branch should only contain simple commits and no merges 30 | $numberOfMergeCommits = git rev-list --min-parents=2 --count "${commitTarget}..${commitSource}" 31 | if ($numberOfMergeCommits -eq "0") 32 | { 33 | Return 34 | } 35 | 36 | $commonError = "Source Branch contains non-linear history. This is only acceptable when merging an older version branch into a newer version. But" 37 | 38 | # find the branch that corresponds to the version before 39 | $index = [array]::IndexOf($versionBranches, $targetBranch) 40 | if ($index -eq "-1") 41 | { 42 | throw "$commonError the Target Branch does not correspond to a release version." 43 | } 44 | $precedingVersionBranch = $versionBranches[$index + 1] 45 | if ($precedingVersionBranch -eq $null) 46 | { 47 | throw "$commonError there is no preceding version that is allowed to be merged into the Target Branch." 48 | } 49 | 50 | # Find the latest commit of the previous Version, that is already merged into the current Verion 51 | git fetch origin $($precedingVersionBranch) 52 | $currentMergeBase = git merge-base "origin/$precedingVersionBranch" "origin/$targetBranch" 53 | 54 | # Find the latest commit of the previous Version, that would become part of the current Version after the merge 55 | # This must be a true descendent of the current merge base, otherwise the merge is not merging the changes from the old version into the new version 56 | $newMergeBase = git merge-base "origin/$precedingVersionBranch" "origin/$sourceBranch" 57 | 58 | if ($currentMergeBase -eq $newMergeBase) 59 | { 60 | throw "$commonError the merge into the $targetBranch branch does not include commits from the $precedingVersionBranch branch (merge bases are equal)." 61 | } 62 | 63 | git merge-base --is-ancestor $currentMergeBase $newMergeBase 64 | if ($LASTEXITCODE -ne "0") 65 | { 66 | throw "$commonError the merge into the $targetBranch branch does not include commits from the $precedingVersionBranch branch (merge bases are in the wrong order)." 67 | } 68 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test deprecated decorator.""" 2 | import re 3 | from unittest.mock import PropertyMock, patch 4 | 5 | from pysweepme._utils import _get_pysweepme_version_tuple, _is_version_reached, deprecated 6 | 7 | 8 | class TestDeprecated: 9 | """Test the deprecated decorator.""" 10 | 11 | def test_tuple_extract(self) -> None: 12 | """Test that further version characters and the end are disregarded.""" 13 | assert _get_pysweepme_version_tuple("1.2.3.4-post1") == (1, 2, 3, 4) 14 | 15 | def test_version_compare(self) -> None: 16 | """Test that version strings are compared semantically.""" 17 | with patch("pysweepme._utils._pysweepme_version", new_callable=PropertyMock(return_value=(1, 2, 3, 4))): 18 | assert _is_version_reached("1.2") is True 19 | assert _is_version_reached("1.2.3.3.99") is True 20 | assert _is_version_reached("1.2.3.4") is True 21 | 22 | assert _is_version_reached("1.2.3.4.1") is False 23 | assert _is_version_reached("1.3") is False 24 | assert _is_version_reached("1.11.3.5") is False 25 | 26 | def test_deprecated_decorator_on_function(self) -> None: 27 | """Test deprecated decorator for simple function.""" 28 | with patch("pysweepme._utils.debug") as mocked_debug: 29 | 30 | @deprecated("1.1", "Do not use.") 31 | def my_func() -> str: 32 | return "success" 33 | 34 | assert my_func() == "success" 35 | assert mocked_debug.call_count == 1 36 | debug_msg = mocked_debug.call_args_list[0].args[0] 37 | assert re.search(r"my_func\(\) .* deprecated .* removed .* Do not use\..*", debug_msg) 38 | 39 | def test_deprecated_decorator_on_method(self) -> None: 40 | """Test deprecated decorator for function of a class.""" 41 | with patch("pysweepme._utils.debug") as mocked_debug: 42 | 43 | class MyClass: 44 | ret_val = "success" 45 | 46 | @deprecated("1.1", "Do not use.") 47 | def my_method(self) -> str: 48 | return self.ret_val 49 | 50 | assert MyClass().my_method() == "success" 51 | assert mocked_debug.call_count == 1 52 | debug_msg = mocked_debug.call_args_list[0].args[0] 53 | assert re.search(r"my_method\(\) .* deprecated .* removed .* Do not use\..*", debug_msg) 54 | 55 | def test_deprecated_decorator_on_class(self) -> None: 56 | """Test deprecated decorator for function of a class.""" 57 | with patch("pysweepme._utils.debug") as mocked_debug: 58 | 59 | @deprecated("1.1", "Do not use.") 60 | class MyClass: 61 | def __init__(self) -> None: 62 | self.check = "success" 63 | 64 | my_class = MyClass() 65 | assert my_class.check == "success" 66 | assert mocked_debug.call_count == 1 67 | debug_msg = mocked_debug.call_args_list[0].args[0] 68 | assert re.search(r"MyClass\(\) .* deprecated .* removed .* Do not use\..*", debug_msg) 69 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | targetenv: 7 | description: 'Target Environment' 8 | required: true 9 | default: 'testpypi' 10 | type: choice 11 | options: 12 | - testpypi 13 | - pypi 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | overview: 20 | name: Overview 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Job Summary 24 | run: | 25 | if [[ "${{ github.ref_name }}" != "main" && "${{ github.ref_name }}" != "1.5.6" && "${{ inputs.targetenv || 'testpypi' }}" == "pypi" ]] 26 | then 27 | { 28 | echo "::error::Publishing to PyPI is only allowed from official branches"; 29 | exit 1; 30 | } 31 | fi; 32 | echo "### Job Summary" >> $GITHUB_STEP_SUMMARY 33 | echo "Publish to ${{ inputs.targetenv || 'testpypi' }} environment from ${{ github.ref_name }} branch." >> $GITHUB_STEP_SUMMARY 34 | publish: 35 | name: Publish 36 | runs-on: windows-2022 37 | needs: overview 38 | environment: ${{ inputs.targetenv || 'testpypi' }} 39 | env: 40 | TWINE_USERNAME: '__token__' 41 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Set up Python 3.9 45 | uses: actions/setup-python@v3 46 | with: 47 | python-version: "3.9" 48 | - name: Install build tools 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install build~=1.2.2 twine~=6.1.0 52 | - name: Build 53 | run: | 54 | python -m build --wheel 55 | - name: Publish distribution 56 | run: | 57 | twine upload --repository-url ${{ vars.PYPI_API_ENDPOINT }} dist/* 58 | - name: Tag commit 59 | id: tag_commit 60 | if: ${{ (github.ref_name == 'main') || (github.ref_name == '1.5.6') || ((inputs.targetenv || 'testpypi') == 'pypi') }} 61 | run: | 62 | pip install --no-index --no-deps --find-links=dist/ pysweepme 63 | $PysweepmeVersion = python -c "import importlib.metadata; print(importlib.metadata.version('pysweepme'))" 64 | if ( -not (Get-ChildItem -Path dist -Filter "*${PysweepmeVersion}*.whl")) 65 | { 66 | Throw "Inspected version $PysweepmeVersion could not be found in any wheels in the dist folder." 67 | } 68 | $TagName = "v${PysweepmeVersion}" 69 | git tag $TagName 70 | git push origin $TagName 71 | "PysweepmeVersion=${PysweepmeVersion}" | Out-File -FilePath $env:GITHUB_OUTPUT -Append 72 | "TagName=${TagName}" | Out-File -FilePath $env:GITHUB_OUTPUT -Append 73 | shell: pwsh 74 | - name: Create github release 75 | if: ${{ (github.ref_name == 'main') || (github.ref_name == '1.5.6') || ((inputs.targetenv || 'testpypi') == 'pypi') }} 76 | uses: ncipollo/release-action@v1 77 | with: 78 | artifacts: "dist/*" 79 | generateReleaseNotes: true 80 | makeLatest: true 81 | name: ${{ format('pysweepme {0}', steps.tag_commit.outputs.PysweepmeVersion) }} 82 | tag: ${{ steps.tag_commit.outputs.TagName }} 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pysweepme" 3 | description = "Load SweepMe! instrument drivers in your own python projects." 4 | readme = "README.md" 5 | requires-python = ">=3.9,<3.12" 6 | license = "MIT" 7 | license-files = ["LICENSE.md"] 8 | maintainers = [ 9 | { name = "SweepMe! GmbH", email = "contact@sweep-me.net" }, 10 | ] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "Operating System :: Microsoft :: Windows", 14 | "Intended Audience :: Developers", 15 | "Intended Audience :: Science/Research", 16 | "Topic :: Scientific/Engineering", 17 | "Topic :: System :: Hardware :: Hardware Drivers", 18 | ] 19 | dynamic = ["version"] 20 | dependencies = [ 21 | "psutil ~= 5.9.3", 22 | "pythonnet ~= 3.0.0.post1", 23 | "pyserial ~= 3.5", 24 | "PyVISA ~= 1.13.0", 25 | ] 26 | 27 | [project.optional-dependencies] 28 | dev = [ 29 | "tox ~= 4.6.1", 30 | "ruff ~= 0.0.272", 31 | "black ~= 23.3.0", 32 | "mypy ~= 1.3.0", 33 | "pytest ~= 7.3.2" 34 | ] 35 | typed = [ 36 | "types-psutil ~= 5.9.3", 37 | "types-pyserial ~=3.5", 38 | ] 39 | 40 | [project.urls] 41 | Homepage = "https://sweep-me.net" 42 | Repository = "https://github.com/SweepMe/pysweepme" 43 | 44 | [build-system] 45 | requires = ["setuptools"] 46 | build-backend = "setuptools.build_meta" 47 | 48 | [tool.setuptools.dynamic] 49 | version = {attr = "pysweepme.__version__"} 50 | 51 | [tool.setuptools.packages.find] 52 | where = ["src"] 53 | 54 | [tool.ruff] 55 | line-length = 120 56 | target-version = "py39" 57 | src = ["src"] 58 | select = [ 59 | "F", 60 | "E", "W", 61 | "C90", 62 | "I", 63 | "N", 64 | "D", 65 | "UP", 66 | "YTT", 67 | "ANN", 68 | "ASYNC", 69 | "S", 70 | "BLE", 71 | "B", 72 | "A", 73 | "COM", 74 | "C4", 75 | "DTZ", 76 | "T10", 77 | "EM", 78 | "FA", 79 | "ISC", 80 | "ICN", 81 | "G", 82 | "INP", 83 | "PIE", 84 | "T20", 85 | "PT", 86 | "Q", 87 | "RSE", 88 | "RET", 89 | "SLF", 90 | "SIM", 91 | "TID", 92 | "INT", 93 | "ARG", 94 | "PTH", 95 | "TD", 96 | "ERA", 97 | "PD", 98 | "PL", 99 | "TRY", 100 | "FLY", 101 | "NPY", 102 | "RUF", 103 | ] 104 | ignore = [ 105 | "ANN101", "ANN102", 106 | "D203", "D213", "D406", "D407", 107 | "G004", # logging does not have any built-in keyword string interpolation for the message itself, falling back to %s etc. is crap 108 | "UP015", # open mode should be clearly stated, explicit is better than implicit 109 | ] 110 | exclude = ["WinFolder.py"] 111 | 112 | [tool.ruff.pydocstyle] 113 | convention = "google" 114 | 115 | [tool.ruff.flake8-annotations] 116 | allow-star-arg-any = true 117 | 118 | [tool.ruff.per-file-ignores] 119 | "tests/*" = ["S101", "SLF001", "INP001"] # Tests should use assert, are allowed to test private internals, and aren't a package 120 | ".github/*" = ["INP001"] 121 | 122 | [tool.black] 123 | line-length = 120 124 | extend-exclude = 'WinFolder\.py' 125 | 126 | [tool.mypy] 127 | strict = true 128 | mypy_path = "src" 129 | exclude = [ 130 | 'WinFolder\.py', 131 | '^build/' 132 | ] 133 | follow_imports = "silent" 134 | 135 | [[tool.mypy.overrides]] 136 | module = "clr.*" 137 | ignore_missing_imports = true 138 | -------------------------------------------------------------------------------- /src/pysweepme/pysweepme_types.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2023 SweepMe! GmbH (sweep-me.net) 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 | 23 | """This module defines types used in pysweepme.""" 24 | 25 | from __future__ import annotations 26 | 27 | from types import TracebackType 28 | from typing import IO, Any, Protocol, Union 29 | 30 | 31 | class FileIOContextProtocol(Protocol): 32 | """Protocol for a ContextManager for IO Operations.""" 33 | 34 | def __enter__(self) -> IO[Any]: 35 | """Function to return a file descriptor.""" 36 | 37 | def __exit__( 38 | self, 39 | exc_type: type[Exception] | None, 40 | exc_value: Exception | None, 41 | traceback: TracebackType | None, 42 | ) -> bool | None: 43 | """Function to close context manager.""" 44 | 45 | 46 | class FileIOProtocolWithoutModifiedCheck(Protocol): 47 | """Protocol for a class with a pathlib.Path compatible open() function that returns a context manager. 48 | 49 | In contrast to Path's open(), this function does not return a file descriptor directly and instead always 50 | must be used in conjunction with a `with` statement that will return the file descriptor of the opened file. 51 | """ 52 | 53 | def open( # noqa: A003, PLR0913 54 | self, 55 | mode: str = "r", 56 | buffering: int = -1, 57 | encoding: str | None = None, 58 | errors: str | None = None, 59 | newline: str | None = None, 60 | ) -> FileIOContextProtocol: 61 | """Function to open a file compatible with pathlib.Path when used in a context manager.""" 62 | 63 | 64 | class FileIOProtocolWithModifiedCheck(FileIOProtocolWithoutModifiedCheck): 65 | """Protocol for a class with a pathlib.Path compatible open() function that returns a context manager. 66 | 67 | In contrast to Path's open(), this function does not return a file descriptor directly and instead always 68 | must be used in conjunction with a `with` statement that will return the file descriptor of the opened file. 69 | """ 70 | 71 | def set_full_read(self) -> None: 72 | """Function that shall be called when a file is read completely. 73 | 74 | If a file was modified by an external program, the user does not need to decide whether to overwrite the file 75 | or not, if the respective file was only read after the external modification. 76 | """ 77 | 78 | 79 | FileIOProtocol = Union[FileIOProtocolWithoutModifiedCheck, FileIOProtocolWithModifiedCheck] 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pysweepme 2 | 3 | [SweepMe!](https://sweep-me.net) is a program to create measurement procedures in short time. The communication with the 4 | instruments is handled via instrument drivers ("Device Classes"), that are python based code snippets. To use these drivers in independent python projects, you can use pysweepme to load them including the creation of interface ports. The package pysweepme outsources parts of SweepMe! as open source MIT licensed code to allow loading drivers in your own scripts. 5 | 6 | ## Installation 7 | So far, only Windows is supported. Other systems might work as well but probably some modifications are needed. 8 | We recommend to use Python 3.9 64bit, as this is the python version and architecture targeted by us. In addition, 9 | several drivers in our [instrument driver repository](https://github.com/SweepMe/instrument-drivers) bring their own 10 | dependencies which are bundled for Python 3.9. 11 | 12 | Use the command line (cmd) to install/uninstall: 13 | 14 | ### install 15 | pip install pysweepme 16 | 17 | ### install with force version 18 | pip install pysweepme==1.5.6.10 19 | 20 | ### uninstall 21 | pip uninstall pysweepme 22 | 23 | ### upgrade 24 | pip install pysweepme --upgrade 25 | 26 | ## Usage 27 | 28 | 1. Copy the drivers to a certain folder in your project folder, e.g "Devices" or to the public folder "CustomDevices". 29 | 2. Import pysweepme to your project. 30 | 3. Use 'get_driver' to load a driver. 31 | 4. See the source code of the driver to see which commands are available. 32 | A general overview of the semantic functions of a driver can be found in the [SweepMe! wiki](https://wiki.sweep-me.net/wiki/Sequencer_procedure). 33 | 34 | ## Example 35 | 36 | ```python 37 | 38 | import pysweepme 39 | 40 | # find a certain folder that is used by SweepMe! 41 | custom_devices_folder = pysweepme.get_path("CUSTOMDEVICES") 42 | 43 | # folder is a path from which instrument drivers will be loaded 44 | # port is a string, e.g. "COM1" or "GPIB0::24::INSTR" 45 | mouse = pysweepme.get_driver("Logger-PC_Mouse", folder=".", port_string="") 46 | mouse.connect() 47 | 48 | print(mouse.read()) 49 | ``` 50 | 51 | ## Version number 52 | The version number of pysweepme correlates with the version number of SweepMe!. For example, pysweepme 1.5.5.x is 53 | related to SweepMe! 1.5.5.x, but the last digit of the version number can differ. 54 | 55 | ## Source code 56 | The source code can be found on github. 57 | 58 | ## Instrument drivers 59 | * Instrument drivers might depend on further python packages that are part of SweepMe! but are not shipped with 60 | pysweepme. In this case, these packages have to be installed using pip by solving the ImportErrors. 61 | * Some Instrument drivers only work with Windows and will not work with other systems, e.g. due to dll files or certain 62 | third-party packages. 63 | * Instrument drivers can be downloaded from https://sweep-me.net/devices or using the version manager in SweepMe!. 64 | Developers can also find the source code of the drivers in our [instrument driver repository on github](https://github.com/SweepMe/instrument-drivers). 65 | * SweepMe! instrument drivers have two purposes. They have semantic standard function to be used in SweepMe! but also 66 | wrap communication commands to easily call them with pysweepme. Not all SweepMe! instrument drivers come with wrapped 67 | communication commands, yet. 68 | 69 | ## Changelog 70 | You can find the list of changes on the [releases page on github](https://github.com/SweepMe/pysweepme/releases). 71 | 72 | The changelog for pysweepme 1.5.5 is still available in the [README of the 1.5.5 branch](https://github.com/SweepMe/pysweepme/blob/v1.5.5.x/README.md#changelog). 73 | -------------------------------------------------------------------------------- /tests/test_device_manager.py: -------------------------------------------------------------------------------- 1 | """Test pysweepme DeviceManager functions.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | from unittest.mock import MagicMock, patch 6 | 7 | from pysweepme.DeviceManager import get_driver, get_driver_instance, get_main_py_path 8 | from pysweepme.EmptyDeviceClass import EmptyDevice 9 | 10 | BITNESS_DISCRIMINATOR = 0x100000000 11 | 12 | 13 | class CustomDevice(EmptyDevice): 14 | """Dummy Device Class.""" 15 | 16 | 17 | class TestDeviceManager: 18 | """Test class for pyweepme DeviceManager functions.""" 19 | 20 | def test_get_main_py_path(self) -> None: 21 | """Test that the correct main.py file depending on architecture is returned.""" 22 | bitness = "64" if sys.maxsize > BITNESS_DISCRIMINATOR else "32" 23 | version = sys.version_info 24 | python_version = f"{version.major}{version.minor}" 25 | specific_exists = True 26 | 27 | def is_file(self: Path) -> bool: 28 | if self.name == "main.py": 29 | return True 30 | if self.name == f"main_{python_version}_{bitness}.py" and specific_exists: 31 | return True 32 | return False 33 | 34 | with patch("DeviceManager.Path.is_file", new=is_file): 35 | path = "C:\\my_dc_dir" 36 | assert get_main_py_path(path) == f"C:\\my_dc_dir\\main_{python_version}_{bitness}.py" 37 | specific_exists = False 38 | assert get_main_py_path(path) == "C:\\my_dc_dir\\main.py" 39 | 40 | def test_get_driver_instance(self) -> None: 41 | """Test that indeed a device instance is returned.""" 42 | folder = "" 43 | expected_folder = "." 44 | name = "my_device" 45 | found_path = "C:\\found\\main.py" 46 | 47 | class LoadSource: 48 | Device = CustomDevice 49 | 50 | with patch("DeviceManager.imp.load_source") as mocked_load_soure, patch( 51 | "DeviceManager.get_main_py_path", 52 | ) as mocked_get_main_py_path: 53 | mocked_load_soure.return_value = LoadSource 54 | mocked_get_main_py_path.return_value = found_path 55 | device = get_driver_instance(folder, name) 56 | assert isinstance(device, CustomDevice) 57 | assert mocked_load_soure.call_count == 1 58 | assert mocked_load_soure.call_args_list[0].args == (name, found_path) 59 | assert mocked_get_main_py_path.call_count == 1 60 | assert mocked_get_main_py_path.call_args_list[0].args == (f"{expected_folder}\\{name}",) 61 | 62 | def test_get_driver(self) -> None: 63 | """Test that get_driver gets the instance and sets the necessery parameters Device and Port, if applicable.""" 64 | name = "my_device" 65 | 66 | def run_test(folder: str, expected_folder: str, port: str) -> None: 67 | device = CustomDevice() 68 | with patch("DeviceManager.get_driver_instance") as mocked_get_driver_instance: 69 | device.set_parameters = MagicMock() # type: ignore[method-assign] 70 | mocked_get_driver_instance.return_value = device 71 | returned_driver = get_driver(name, folder, port_string=port) 72 | assert returned_driver is device 73 | assert mocked_get_driver_instance.call_count == 1 74 | assert mocked_get_driver_instance.call_args_list[0].args == (expected_folder, name) 75 | assert device.set_parameters.call_count == 1 76 | expected_gui_params = {"Device": name, "Port": port} if port else {"Device": name} 77 | assert device.set_parameters.call_args_list[0].args == (expected_gui_params,) 78 | 79 | run_test("C:\\my_dc_dir", "C:\\my_dc_dir", "") 80 | run_test(".", ".", "COM007") 81 | -------------------------------------------------------------------------------- /src/pysweepme/ErrorMessage.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | 23 | from __future__ import annotations 24 | 25 | import os 26 | from time import localtime 27 | from traceback import print_exc 28 | from typing import Callable 29 | 30 | 31 | def try_to_print_traceback() -> None: 32 | """Print a traceback for the most recent python exception. 33 | 34 | If there is a UnicodeDecodeError while trying to print the exception, e.g. 35 | because a Python source file containing an error is using non UTF-8 encoding, 36 | the function tries to monkey patch the traceback module to skip reading lines 37 | from the file and still try to print as many error details as possible. 38 | """ 39 | try: 40 | print_exc() 41 | except UnicodeDecodeError: 42 | import traceback 43 | 44 | # monkeypatching the linecache module 45 | # so UnicodeDecodeErrors while trying to read a file don't lead to an 46 | # uncaught Exception but only "empty" lines in the traceback 47 | original_updatecache: Callable[ 48 | [str, dict[str, object] | None], 49 | list[str], 50 | ] = traceback.linecache.updatecache # type: ignore[attr-defined] 51 | 52 | def try_updatecache(filename: str, module_globals: dict[str, object] | None = None) -> list[str]: 53 | try: 54 | return original_updatecache(filename, module_globals) 55 | except Exception: # noqa: BLE001 - error handling should also catch any unexpected errors 56 | print("") # noqa: T201 57 | return [] 58 | 59 | traceback.linecache.updatecache = try_updatecache # type: ignore[attr-defined] 60 | print_exc() 61 | 62 | 63 | def error(*args: object) -> None: 64 | """Print arguments to the debug log including an exception stacktrace. 65 | 66 | Args: 67 | *args: The arguments to print to the debug log. 68 | """ 69 | year, month, day, hour, min, sec = localtime()[:6] 70 | print("-" * 60) 71 | print("Time: %s.%s.%s %02d:%02d:%02d" % (day, month, year, hour, min, sec)) 72 | if len(args) > 0: 73 | print("Message:", *args) 74 | print("Python Error:") 75 | try_to_print_traceback() 76 | print("-" * 60) 77 | 78 | 79 | def debug(*args: object, debugmode_only: bool = False) -> None: 80 | """Print arguments to the debug log. 81 | 82 | Args: 83 | *args: The arguments to print to the debug log. 84 | debugmode_only: True if the arguments shall be printed only when debug mode is on. 85 | """ 86 | debug_mode = os.environ["SWEEPME_DEBUGMODE"] == "True" if "SWEEPME_DEBUGMODE" in os.environ else False 87 | 88 | if (not debugmode_only or debug_mode) and len(args) > 0: 89 | year, month, day, hour, min, sec = localtime()[:6] 90 | print("-" * 60) 91 | print("Debug: %s.%s.%s %02d:%02d:%02d\t" % (day, month, year, hour, min, sec), *args) 92 | 93 | 94 | def debug_only(*args: object) -> None: 95 | """Print arguments to the debug log if debug mode is on. 96 | 97 | Args: 98 | *args: The arguments to print to the debug log. 99 | """ 100 | debug(*args, debugmode_only=True) 101 | -------------------------------------------------------------------------------- /src/pysweepme/DeviceManager.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | 23 | 24 | import imp 25 | import os 26 | import types 27 | from pathlib import Path 28 | 29 | from ._utils import deprecated 30 | from .Architecture import version_info 31 | from .EmptyDeviceClass import EmptyDevice 32 | from .ErrorMessage import error 33 | from .FolderManager import addFolderToPATH 34 | from .PortManager import PortManager 35 | 36 | 37 | def get_main_py_path(path: str) -> str: 38 | """Find the main python file matching the current architecture best. 39 | 40 | In a given folder, look for the main.py file that matches the running python version and bitness. 41 | Return the generic main.py path if the specific one does not exist. 42 | 43 | Args: 44 | path: Path to the folder that contains the main.py file 45 | 46 | Returns: 47 | The path to the main.py file. 48 | """ 49 | test_file = path + os.sep + f"main_{version_info.python_suffix}.py" 50 | if Path(test_file).is_file(): 51 | return test_file 52 | return path + os.sep + "main.py" 53 | 54 | 55 | def get_driver_module(folder: str, name: str) -> types.ModuleType: 56 | """Load the module containing the requested driver. 57 | 58 | Args: 59 | folder: The folder containing the drivers. 60 | name: The name of the driver 61 | 62 | Returns: 63 | The loaded module containing the requested driver. 64 | """ 65 | if folder == "": 66 | folder = "." 67 | name = name.strip(r"\/") 68 | 69 | try: 70 | # Loads .py file as module 71 | module = imp.load_source(name, get_main_py_path(folder + os.sep + name)) 72 | except Exception as e: # noqa: BLE001 73 | # We don't know what could go wrong, so we catch all exceptions, log the error, and raise an Exception again 74 | error() 75 | msg = f"Cannot load Driver '{name}' from folder {folder}." 76 | if isinstance(e, FileNotFoundError): 77 | msg += " Please change folder or copy Driver to your project." 78 | raise ImportError(msg) from e 79 | 80 | return module 81 | 82 | 83 | def get_driver_class(folder: str, name: str) -> type[EmptyDevice]: 84 | """Get the class (not an instance) of the requested driver. 85 | 86 | Args: 87 | folder: The folder containing the drivers. 88 | name: The name of the driver 89 | 90 | Returns: 91 | The class of the requested driver. 92 | """ 93 | # Add the libs or library folder to the path before loading the driver 94 | driver_path = Path(folder) / name 95 | addFolderToPATH(str(driver_path)) 96 | 97 | module = get_driver_module(folder, name) 98 | driver: type[EmptyDevice] = module.Device 99 | return driver 100 | 101 | 102 | def get_driver_instance(folder: str, name: str) -> EmptyDevice: 103 | """Create a bare driver instance. 104 | 105 | Create a bare driver instance without input cleanup and without setting GUI parameters. 106 | 107 | Args: 108 | folder: General folder in which to look for drivers. 109 | name: Name of the driver being the name of the driver folder. 110 | 111 | Returns: 112 | Device object of the driver. 113 | """ 114 | driver_class = get_driver_class(folder, name) 115 | 116 | return driver_class() 117 | 118 | 119 | def setup_driver(driver: EmptyDevice, name: str, port_string: str) -> None: 120 | """Set port and device. 121 | 122 | The GUI parameters Device and Port (if provided) are set. 123 | When using the Port Manager, the port object is attached to the driver. 124 | 125 | Args: 126 | driver: The driver instance. 127 | name: The name of the driver. 128 | port_string: The string defining the port to use for the driver. 129 | """ 130 | if port_string != "": 131 | if driver.port_manager: 132 | port_manager = PortManager() 133 | port = port_manager.get_port(port_string, driver.port_properties) 134 | driver.set_port(port) 135 | 136 | driver.set_parameters({"Port": port_string, "Device": name}) 137 | 138 | else: 139 | driver.set_parameters({"Device": name}) 140 | 141 | 142 | def get_driver(name: str, folder: str = ".", port_string: str = "") -> EmptyDevice: 143 | """Create a driver instance. 144 | 145 | When the driver uses the port manager, the port will already be opened, but the connect() function of 146 | the driver must be called in any case. 147 | 148 | Args: 149 | name: Name of the driver being the name of the driver folder 150 | folder: (optional) General folder to look for drivers, If folder is not used or empty, the driver is loaded 151 | from the folder of the running script/project 152 | port_string: (optional) A port resource name as selected in SweepMe! such as 'COM1', 'GPIB0::1::INSTR', etc. 153 | It is required if the driver connects to an instrument and needs to open a specific port. 154 | 155 | Returns: 156 | Initialized Device object of the driver with port and default parameters set. 157 | """ 158 | name = name.strip(r"\/") 159 | 160 | driver = get_driver_instance(folder, name) 161 | setup_driver(driver, name, port_string) 162 | 163 | return driver 164 | 165 | 166 | get_device = deprecated("1.5.8", "Use get_driver() instead.", name="get_device")(get_driver) 167 | -------------------------------------------------------------------------------- /tests/test_empty_device.py: -------------------------------------------------------------------------------- 1 | """Test function of the EmptyDevice class.""" 2 | from copy import deepcopy 3 | from typing import Any 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from pysweepme.EmptyDeviceClass import EmptyDevice 9 | 10 | default_params: dict[str, Any] = { 11 | "x": 1, 12 | "y": 2, 13 | } 14 | 15 | special_params: dict[str, Any] = { 16 | "x": 5, 17 | "y": 8, 18 | } 19 | 20 | 21 | class CustomDevice(EmptyDevice): 22 | """Dummy Device Class.""" 23 | 24 | passed_parameters: dict[str, Any] 25 | 26 | def __init__(self, default_params: dict[str, Any]) -> None: 27 | """Save the passed default_params for later use.""" 28 | super().__init__() 29 | self.default_params = deepcopy(default_params) 30 | 31 | def set_GUIparameter(self) -> dict[str, Any]: # noqa: N802 32 | """Return the default params of the DC.""" 33 | return self.default_params 34 | 35 | def get_GUIparameter(self, parameter: dict[str, Any]) -> None: # noqa: N802 36 | """Simulate to work with the passed parameters. Instead just save them to a instance variable.""" 37 | self.passed_parameters = parameter 38 | 39 | 40 | class TestResetLatestParameters: 41 | """Tests for the reset_latest_parameters function.""" 42 | 43 | def setup_method(self) -> None: 44 | """Prepare a dummy device instance for all tests.""" 45 | self.device = CustomDevice(default_params) 46 | 47 | def test_vanilla(self) -> None: 48 | """Test initializing the internally saved parameters for the first time.""" 49 | # at the beginning, parameters are not set yet 50 | assert self.device._latest_parameters is None 51 | self.device.reset_latest_parameters() 52 | assert self.device._latest_parameters == default_params 53 | 54 | def test_reset_values(self) -> None: 55 | """Test resetting internally saved parameters after already being present.""" 56 | self.device._latest_parameters = special_params 57 | self.device.reset_latest_parameters() 58 | assert self.device._latest_parameters == default_params 59 | 60 | def test_reset_keeps_device_and_port(self) -> None: 61 | """Test that resetting parameters keeps the device/port properties.""" 62 | name = "MySuperDevice" 63 | self.device._latest_parameters = {**special_params, "Device": name} 64 | self.device.reset_latest_parameters() 65 | assert self.device._latest_parameters == {**default_params, "Device": name} 66 | 67 | port = "COM007" 68 | self.device._latest_parameters = {**special_params, "Port": port} 69 | self.device.reset_latest_parameters() 70 | assert self.device._latest_parameters == {**default_params, "Port": port} 71 | 72 | def test_list_defaults(self) -> None: 73 | """Test that the first element is selected for list parameters.""" 74 | self.device.default_params = { 75 | **default_params, 76 | "listproperty": ["first", "second", "third"], 77 | } 78 | self.device.reset_latest_parameters() 79 | assert self.device._latest_parameters == { 80 | **default_params, 81 | "listproperty": "first", 82 | } 83 | 84 | 85 | class TestGetParameters: 86 | """Tests for the get_parameters function.""" 87 | 88 | def setup_method(self) -> None: 89 | """Prepare a dummy device instance for all tests.""" 90 | self.device = CustomDevice(default_params) 91 | 92 | def test_vanilla(self) -> None: 93 | """Test that calling the function initializes the latest_parameters.""" 94 | # at the beginning, parameters are not set yet 95 | with patch.object( 96 | self.device, 97 | "reset_latest_parameters", 98 | wraps=self.device.reset_latest_parameters, 99 | ) as mocked_function: 100 | assert self.device._latest_parameters is None 101 | params = self.device.get_parameters() 102 | assert self.device._latest_parameters == default_params 103 | assert params == default_params 104 | assert mocked_function.call_count == 1 105 | 106 | def test_existing_parameters(self) -> None: 107 | """Test that existing latest_parameters are not reset when calling get.""" 108 | with patch.object( 109 | self.device, 110 | "reset_latest_parameters", 111 | wraps=self.device.reset_latest_parameters, 112 | ) as mocked_function: 113 | self.device._latest_parameters = special_params 114 | params = self.device.get_parameters() 115 | assert self.device._latest_parameters == special_params 116 | assert params == special_params 117 | assert mocked_function.call_count == 0 118 | 119 | 120 | class TestSetParameters: 121 | """Tests for the set_parameters function.""" 122 | 123 | def setup_method(self) -> None: 124 | """Prepare a dummy device instance for all tests.""" 125 | self.device = CustomDevice(default_params) 126 | 127 | def test_with_port(self) -> None: 128 | """Test that calling the function initializes the latest_parameters and sets the provided port.""" 129 | # at the beginning, parameters are not set yet 130 | assert self.device._latest_parameters is None 131 | port = "COM007" 132 | self.device.set_parameters({"Port": port}) 133 | assert self.device._latest_parameters == {**default_params, "Port": port} 134 | 135 | def test_with_value(self) -> None: 136 | """Test that calling the function with one of the keys of the default but a different value.""" 137 | assert self.device._latest_parameters is None 138 | self.device.set_parameters({"x": 99}) 139 | assert self.device._latest_parameters == {"x": 99, "y": default_params["y"]} 140 | 141 | def test_overwrite_partial_parameters(self) -> None: 142 | """Test that setting some parameters does not reset others.""" 143 | self.device._latest_parameters = special_params 144 | self.device.set_parameters({"x": 99}) 145 | assert self.device._latest_parameters == {"x": 99, "y": special_params["y"]} 146 | 147 | def test_without_argument(self) -> None: 148 | """Test that the function just initialized parameters when called without arguments.""" 149 | assert self.device._latest_parameters is None 150 | self.device.set_parameters() 151 | assert self.device._latest_parameters == default_params 152 | 153 | def test_unsupported_parameters(self) -> None: 154 | """Test that an exception is raised when an unsupported parameter is passed.""" 155 | with pytest.raises(ValueError, match=r".* 'nonsense' not supported .*"): 156 | self.device.set_parameters({"nonsense": 42}) 157 | -------------------------------------------------------------------------------- /src/pysweepme/UserInterface.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | 23 | from __future__ import annotations 24 | 25 | import time 26 | from typing import Callable 27 | 28 | from .FolderManager import getFoMa 29 | 30 | 31 | class UIHandler: 32 | """Manages calls to user interface functions and allows applications to register alternative functions.""" 33 | 34 | _get_input: Callable[[str], str] | None = None 35 | _message_box: Callable[[str, bool], None] | None = None 36 | _message_info: Callable[[str], None] | None = None 37 | _message_balloon: Callable[[str], None] | None = None 38 | 39 | @classmethod 40 | def register_get_input(cls, get_input_function: Callable[[str], str]) -> None: 41 | """Register a handler for the get_input() function. 42 | 43 | Args: 44 | get_input_function: Function that shall handle calls to get_input(). 45 | 46 | """ 47 | cls._get_input = get_input_function 48 | 49 | @classmethod 50 | def get_input(cls, msg: str) -> str: 51 | """Ask the user for input. 52 | 53 | This function can be used by drivers or CustomFunction scripts to ask the user for input. If used with SweepMe!, 54 | this function will be overwritten by SweepMe! to create a graphical user interface. 55 | 56 | Args: 57 | msg: String of the message that is displayed to the user. 58 | 59 | Returns: 60 | str: response message 61 | """ 62 | if callable(cls._get_input): 63 | return cls._get_input(msg) 64 | 65 | return input(str(msg)) 66 | 67 | @classmethod 68 | def register_message_box(cls, message_box_function: Callable[[str, bool], None]) -> None: 69 | """Register a handler for the message_box() function. 70 | 71 | Args: 72 | message_box_function: Function that shall handle calls to message_box(). 73 | """ 74 | cls._message_box = message_box_function 75 | 76 | @classmethod 77 | def message_box(cls, msg: str, blocking: bool) -> None: 78 | """Show a message to the user. 79 | 80 | This function can be used by drivers or CustomFunction scripts to show a message to the user. 81 | If used with SweepMe!, this function will be overwritten by SweepMe! to create a graphical message box. 82 | By default, it will be just printed to the console. 83 | 84 | Args: 85 | msg: String of the message that is displayed to the user. 86 | blocking: True to require the user to acknowledge the message. 87 | """ 88 | if callable(cls._message_box): 89 | cls._message_box(msg, blocking) 90 | return 91 | 92 | if blocking: 93 | # This makes sure that the use needs to accept the dialog before the measurement continues 94 | input(f"Message (confirm with enter): {msg!s}") 95 | else: 96 | # The message is just shown with a print which should work in every application that uses pysweepme 97 | print(f"Message: {msg!s}") # noqa: T201 98 | 99 | @classmethod 100 | def register_message_info(cls, message_info_function: Callable[[str], None]) -> None: 101 | """Register a handler for the message_info() function. 102 | 103 | Args: 104 | message_info_function: Function that shall handle calls to message_info(). 105 | """ 106 | cls._message_info = message_info_function 107 | 108 | @classmethod 109 | def message_info(cls, msg: str) -> None: 110 | """Show an info to the user. 111 | 112 | This function can be used by drivers or CustomFunction scripts to show a message to the user. 113 | If used with SweepMe!, this function will be overwritten by SweepMe! to add an entry in the general tab 114 | of the main window. By default, it will be just printed to the console. 115 | 116 | Args: 117 | msg: String of the message that is displayed to the user. 118 | """ 119 | if callable(cls._message_info): 120 | cls._message_info(msg) 121 | return 122 | 123 | # The message is just shown with a print which should work in every application that uses pysweepme 124 | print("Info:", msg) # noqa: T201 125 | 126 | @classmethod 127 | def register_message_balloon(cls, message_balloon_function: Callable[[str], None]) -> None: 128 | """Register a handler for the message_balloon() function. 129 | 130 | Args: 131 | message_balloon_function: Function that shall handle calls to message_balloon(). 132 | """ 133 | cls._message_balloon = message_balloon_function 134 | 135 | @classmethod 136 | def message_balloon(cls, msg: str) -> None: 137 | """Show a message balloon to the user. 138 | 139 | This function can be used by drivers or CustomFunction scripts to show a message to the user. 140 | If used with SweepMe!, this function will be overwritten by SweepMe! to create a message balloon in the 141 | system tray. By default, it will be just printed to the console. 142 | 143 | Args: 144 | msg: String of the message that is displayed to the user. 145 | """ 146 | if callable(cls._message_balloon): 147 | cls._message_balloon(msg) 148 | return 149 | 150 | # The message is just shown with a print which should work in every application that uses pysweepme 151 | print("Balloon message:", msg) # noqa: T201 152 | 153 | 154 | get_input = UIHandler.get_input 155 | message_box = UIHandler.message_box 156 | message_info = UIHandler.message_info 157 | message_balloon = UIHandler.message_balloon 158 | 159 | 160 | def message_log(msg, logfilepath=None): 161 | if not logfilepath: 162 | logfilepath = getFoMa().get_file("LOGBOOK") 163 | 164 | with open(logfilepath, "a") as logfile: 165 | year, month, day, hour, min, sec = time.localtime()[:6] 166 | logfile.write("%02d/%02d/%04d %02d:%02d:%02d" % (day, month, year, hour, min, sec) + " - " + str(msg) + "\n") 167 | -------------------------------------------------------------------------------- /src/pysweepme/Config.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2023 SweepMe! GmbH (sweep-me.net) 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 | 23 | from __future__ import annotations 24 | 25 | import os 26 | from configparser import ConfigParser 27 | from pathlib import Path 28 | from typing import Callable, cast 29 | 30 | from .ErrorMessage import error 31 | from .pysweepme_types import FileIOProtocol 32 | 33 | 34 | class DefaultFileIO: 35 | """Manages which class shall be used to perform File IO operations. 36 | 37 | By default, pathlib.Path() will be used, but the application may register an alternative default that behaves 38 | the same when calling open() in a `with` statement. 39 | """ 40 | 41 | custom_default_file_io: tuple[Callable[[Path | str], FileIOProtocol]] | None = None 42 | 43 | @classmethod 44 | def register_custom_default(cls, function: Callable[[Path | str], FileIOProtocol]) -> None: 45 | """Register an alternative default for creating a Path-compatible instance for file IO operations. 46 | 47 | Args: 48 | function: Function that accepts a Path or string representing the file and returns an instance that behaves 49 | like pathlib.Path() 50 | """ 51 | # use a tuple to prevent python from considering it a bound method 52 | cls.custom_default_file_io = (function,) 53 | 54 | @staticmethod 55 | def pysweepme_default_fileio(file: Path | str) -> FileIOProtocol: 56 | """Create a pathlib.Path instance. 57 | 58 | Args: 59 | file: Path or string representing the file. 60 | 61 | Returns: 62 | Path instance for the requested argument. 63 | """ 64 | # Path has more sophisticated signatures than the FileIOProtocol, so we need to cast the type 65 | return cast(FileIOProtocol, Path(file)) 66 | 67 | @staticmethod 68 | def default_fileio(file: Path | str) -> FileIOProtocol: 69 | """Create a pathlib.Path() compatible instance for the requested file. 70 | 71 | If the application did not register an alternative default, a pathlib.Path() instance will be returned. 72 | 73 | Args: 74 | file: Path or string representing the file. 75 | 76 | Returns: An instance that behaves like pathlib.Path() 77 | 78 | """ 79 | if DefaultFileIO.custom_default_file_io: 80 | return DefaultFileIO.custom_default_file_io[0](file) 81 | return DefaultFileIO.pysweepme_default_fileio(file) 82 | 83 | 84 | class Config(ConfigParser): 85 | """Convenience wrapper around ConfigParser to quickly access config files.""" 86 | 87 | def __init__( 88 | self, 89 | file_name: Path | str, 90 | custom_reader_writer: Callable[[Path | str], FileIOProtocol] = DefaultFileIO.default_fileio, 91 | ) -> None: 92 | """Create a Config instance. 93 | 94 | The Config instance should always be created directly before it is used, to make use of the incremental write 95 | capabilities that reduce conflicts when using multiple instances of the application. That way, the Config class 96 | will read the current config ini file, apply the requested changes only to the respective section and key, and 97 | write those changes without overwriting other sections/keys that might have been modified by another program. 98 | 99 | Args: 100 | file_name: The path (Path object or string) to the config file. 101 | Deprecated: A string containing the contents of an ini file. 102 | custom_reader_writer: If this argument is not provided (recommended), the file operations will be performed 103 | using python's pathlib.Path() (unless the application registered another default). 104 | This argument can be a function returning another instance of a pathlib.Path() 105 | compatible class that shall be used for file IO operations only for this config 106 | instance. 107 | """ 108 | super().__init__() 109 | 110 | self.optionxform = str # type: ignore 111 | 112 | self.file_name = file_name 113 | 114 | self.reader_writer = custom_reader_writer(file_name) 115 | 116 | def setFileName(self, file_name): 117 | """Deprecated.""" 118 | self.set_filename(file_name) 119 | 120 | def set_filename(self, file_name): 121 | self.file_name = file_name 122 | 123 | def isConfigFile(self): 124 | """Deprecated.""" 125 | return self.is_file() 126 | 127 | def is_file(self): 128 | try: 129 | return bool(os.path.isfile(self.file_name)) 130 | except: 131 | error() 132 | 133 | return False 134 | 135 | def readConfigFile(self): 136 | """Deprecated.""" 137 | return self.load_file() 138 | 139 | def load_file(self) -> bool: 140 | try: 141 | if self.is_file(): 142 | with self.reader_writer.open("r", encoding="utf-8") as cf: 143 | self.read_file(cf) 144 | if hasattr(self.reader_writer, "set_full_read"): 145 | self.reader_writer.set_full_read() 146 | elif isinstance(self.file_name, str): 147 | # apparently the Config instance is not a valid, existing file, so we try to read it as the content 148 | # of an ini file 149 | self.read_string(self.file_name) 150 | else: 151 | msg = f"The config file {self.file_name!s} does not exist and thus cannot be read." 152 | ValueError(msg) 153 | except: 154 | error() 155 | return False 156 | 157 | return True 158 | 159 | def makeConfigFile(self): 160 | """Deprecated.""" 161 | return self.create_file() 162 | 163 | def create_file(self): 164 | try: 165 | if not self.is_file(): 166 | if not os.path.exists(os.path.dirname(self.file_name)): 167 | os.mkdir(os.path.dirname(self.file_name)) 168 | 169 | with self.reader_writer.open("w", encoding="utf-8") as cf: 170 | self.write(cf) 171 | 172 | return True 173 | except: 174 | error() 175 | 176 | return False 177 | 178 | def setConfigSection(self, section): 179 | """Deprecated.""" 180 | return self.set_section(section) 181 | 182 | def set_section(self, section: str) -> bool: 183 | try: 184 | if self.load_file(): 185 | if not self.has_section(section): 186 | self.add_section(section) 187 | with self.reader_writer.open("w", encoding="utf-8") as cf: 188 | self.write(cf) 189 | except: 190 | error() 191 | return False 192 | 193 | return True 194 | 195 | def setConfigOption(self, section, option, value): 196 | """Deprecated.""" 197 | return self.set_option(section, option, value) 198 | 199 | def set_option(self, section: str, option: str, value: str) -> bool: 200 | try: 201 | self.set_section(section) 202 | self.set(section, option, value) 203 | with self.reader_writer.open("w", encoding="utf-8") as cf: 204 | self.write(cf) 205 | except: 206 | error() 207 | return False 208 | 209 | return True 210 | 211 | def removeConfigOption(self, section: str, option: str) -> bool: 212 | try: 213 | if self.load_file() and self.has_section(section) and self.has_option(section, option): 214 | self.remove_option(section, option) 215 | with self.reader_writer.open("w", encoding="utf-8") as cf: 216 | self.write(cf) 217 | return True 218 | except: 219 | error() 220 | 221 | return False 222 | 223 | def getConfigSections(self): 224 | """Deprecated.""" 225 | return self.get_sections() 226 | 227 | def get_sections(self): 228 | if self.load_file(): 229 | return self.sections() 230 | else: 231 | return [] 232 | 233 | def getConfigOption(self, section, option): 234 | """Deprecated.""" 235 | return self.get_value(section, option) 236 | 237 | def get_value(self, section, option): 238 | if self.load_file() and section in self: 239 | if option.lower() in self[section]: 240 | return self[section][option.lower()] 241 | elif option in self[section]: 242 | return self[section][option] 243 | return False 244 | 245 | def getConfigOptions(self, section): 246 | """Deprecated.""" 247 | return self.get_options(section) 248 | 249 | def get_options(self, section): 250 | vals = {} 251 | if self.load_file() and section in self: 252 | for key in self[section]: 253 | vals[key] = self[section][key] 254 | return vals 255 | 256 | def getConfig(self): 257 | """Deprecated.""" 258 | return self.get_values() 259 | 260 | def get_values(self): 261 | return {section: self.getConfigOptions(section) for section in self.getConfigSections()} 262 | -------------------------------------------------------------------------------- /src/pysweepme/WinFolder.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014 Michael Kropat 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all 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 21 | # THE SOFTWARE. 22 | 23 | import ctypes, sys 24 | from ctypes import windll, wintypes 25 | from uuid import UUID 26 | 27 | class GUID(ctypes.Structure): # [1] 28 | _fields_ = [ 29 | ("Data1", wintypes.DWORD), 30 | ("Data2", wintypes.WORD), 31 | ("Data3", wintypes.WORD), 32 | ("Data4", wintypes.BYTE * 8) 33 | ] 34 | 35 | def __init__(self, uuid_): 36 | ctypes.Structure.__init__(self) 37 | self.Data1, self.Data2, self.Data3, self.Data4[0], self.Data4[1], rest = uuid_.fields 38 | for i in range(2, 8): 39 | self.Data4[i] = rest>>(8 - i - 1)*8 & 0xff 40 | 41 | class FOLDERID: # [2] 42 | AccountPictures = UUID('{008ca0b1-55b4-4c56-b8a8-4de4b299d3be}') 43 | AdminTools = UUID('{724EF170-A42D-4FEF-9F26-B60E846FBA4F}') 44 | ApplicationShortcuts = UUID('{A3918781-E5F2-4890-B3D9-A7E54332328C}') 45 | CameraRoll = UUID('{AB5FB87B-7CE2-4F83-915D-550846C9537B}') 46 | CDBurning = UUID('{9E52AB10-F80D-49DF-ACB8-4330F5687855}') 47 | CommonAdminTools = UUID('{D0384E7D-BAC3-4797-8F14-CBA229B392B5}') 48 | CommonOEMLinks = UUID('{C1BAE2D0-10DF-4334-BEDD-7AA20B227A9D}') 49 | CommonPrograms = UUID('{0139D44E-6AFE-49F2-8690-3DAFCAE6FFB8}') 50 | CommonStartMenu = UUID('{A4115719-D62E-491D-AA7C-E74B8BE3B067}') 51 | CommonStartup = UUID('{82A5EA35-D9CD-47C5-9629-E15D2F714E6E}') 52 | CommonTemplates = UUID('{B94237E7-57AC-4347-9151-B08C6C32D1F7}') 53 | Contacts = UUID('{56784854-C6CB-462b-8169-88E350ACB882}') 54 | Cookies = UUID('{2B0F765D-C0E9-4171-908E-08A611B84FF6}') 55 | Desktop = UUID('{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}') 56 | DeviceMetadataStore = UUID('{5CE4A5E9-E4EB-479D-B89F-130C02886155}') 57 | Documents = UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}') 58 | DocumentsLibrary = UUID('{7B0DB17D-9CD2-4A93-9733-46CC89022E7C}') 59 | Downloads = UUID('{374DE290-123F-4565-9164-39C4925E467B}') 60 | Favorites = UUID('{1777F761-68AD-4D8A-87BD-30B759FA33DD}') 61 | Fonts = UUID('{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}') 62 | GameTasks = UUID('{054FAE61-4DD8-4787-80B6-090220C4B700}') 63 | History = UUID('{D9DC8A3B-B784-432E-A781-5A1130A75963}') 64 | ImplicitAppShortcuts = UUID('{BCB5256F-79F6-4CEE-B725-DC34E402FD46}') 65 | InternetCache = UUID('{352481E8-33BE-4251-BA85-6007CAEDCF9D}') 66 | Libraries = UUID('{1B3EA5DC-B587-4786-B4EF-BD1DC332AEAE}') 67 | Links = UUID('{bfb9d5e0-c6a9-404c-b2b2-ae6db6af4968}') 68 | LocalAppData = UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}') 69 | LocalAppDataLow = UUID('{A520A1A4-1780-4FF6-BD18-167343C5AF16}') 70 | LocalizedResourcesDir = UUID('{2A00375E-224C-49DE-B8D1-440DF7EF3DDC}') 71 | Music = UUID('{4BD8D571-6D19-48D3-BE97-422220080E43}') 72 | MusicLibrary = UUID('{2112AB0A-C86A-4FFE-A368-0DE96E47012E}') 73 | NetHood = UUID('{C5ABBF53-E17F-4121-8900-86626FC2C973}') 74 | OriginalImages = UUID('{2C36C0AA-5812-4b87-BFD0-4CD0DFB19B39}') 75 | PhotoAlbums = UUID('{69D2CF90-FC33-4FB7-9A0C-EBB0F0FCB43C}') 76 | PicturesLibrary = UUID('{A990AE9F-A03B-4E80-94BC-9912D7504104}') 77 | Pictures = UUID('{33E28130-4E1E-4676-835A-98395C3BC3BB}') 78 | Playlists = UUID('{DE92C1C7-837F-4F69-A3BB-86E631204A23}') 79 | PrintHood = UUID('{9274BD8D-CFD1-41C3-B35E-B13F55A758F4}') 80 | Profile = UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}') 81 | ProgramData = UUID('{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}') 82 | ProgramFiles = UUID('{905e63b6-c1bf-494e-b29c-65b732d3d21a}') 83 | ProgramFilesX64 = UUID('{6D809377-6AF0-444b-8957-A3773F02200E}') 84 | ProgramFilesX86 = UUID('{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}') 85 | ProgramFilesCommon = UUID('{F7F1ED05-9F6D-47A2-AAAE-29D317C6F066}') 86 | ProgramFilesCommonX64 = UUID('{6365D5A7-0F0D-45E5-87F6-0DA56B6A4F7D}') 87 | ProgramFilesCommonX86 = UUID('{DE974D24-D9C6-4D3E-BF91-F4455120B917}') 88 | Programs = UUID('{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}') 89 | Public = UUID('{DFDF76A2-C82A-4D63-906A-5644AC457385}') 90 | PublicDesktop = UUID('{C4AA340D-F20F-4863-AFEF-F87EF2E6BA25}') 91 | PublicDocuments = UUID('{ED4824AF-DCE4-45A8-81E2-FC7965083634}') 92 | PublicDownloads = UUID('{3D644C9B-1FB8-4f30-9B45-F670235F79C0}') 93 | PublicGameTasks = UUID('{DEBF2536-E1A8-4c59-B6A2-414586476AEA}') 94 | PublicLibraries = UUID('{48DAF80B-E6CF-4F4E-B800-0E69D84EE384}') 95 | PublicMusic = UUID('{3214FAB5-9757-4298-BB61-92A9DEAA44FF}') 96 | PublicPictures = UUID('{B6EBFB86-6907-413C-9AF7-4FC2ABF07CC5}') 97 | PublicRingtones = UUID('{E555AB60-153B-4D17-9F04-A5FE99FC15EC}') 98 | PublicUserTiles = UUID('{0482af6c-08f1-4c34-8c90-e17ec98b1e17}') 99 | PublicVideos = UUID('{2400183A-6185-49FB-A2D8-4A392A602BA3}') 100 | QuickLaunch = UUID('{52a4f021-7b75-48a9-9f6b-4b87a210bc8f}') 101 | Recent = UUID('{AE50C081-EBD2-438A-8655-8A092E34987A}') 102 | RecordedTVLibrary = UUID('{1A6FDBA2-F42D-4358-A798-B74D745926C5}') 103 | ResourceDir = UUID('{8AD10C31-2ADB-4296-A8F7-E4701232C972}') 104 | Ringtones = UUID('{C870044B-F49E-4126-A9C3-B52A1FF411E8}') 105 | RoamingAppData = UUID('{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}') 106 | RoamedTileImages = UUID('{AAA8D5A5-F1D6-4259-BAA8-78E7EF60835E}') 107 | RoamingTiles = UUID('{00BCFC5A-ED94-4e48-96A1-3F6217F21990}') 108 | SampleMusic = UUID('{B250C668-F57D-4EE1-A63C-290EE7D1AA1F}') 109 | SamplePictures = UUID('{C4900540-2379-4C75-844B-64E6FAF8716B}') 110 | SamplePlaylists = UUID('{15CA69B3-30EE-49C1-ACE1-6B5EC372AFB5}') 111 | SampleVideos = UUID('{859EAD94-2E85-48AD-A71A-0969CB56A6CD}') 112 | SavedGames = UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}') 113 | SavedSearches = UUID('{7d1d3a04-debb-4115-95cf-2f29da2920da}') 114 | Screenshots = UUID('{b7bede81-df94-4682-a7d8-57a52620b86f}') 115 | SearchHistory = UUID('{0D4C3DB6-03A3-462F-A0E6-08924C41B5D4}') 116 | SearchTemplates = UUID('{7E636BFE-DFA9-4D5E-B456-D7B39851D8A9}') 117 | SendTo = UUID('{8983036C-27C0-404B-8F08-102D10DCFD74}') 118 | SidebarDefaultParts = UUID('{7B396E54-9EC5-4300-BE0A-2482EBAE1A26}') 119 | SidebarParts = UUID('{A75D362E-50FC-4fb7-AC2C-A8BEAA314493}') 120 | SkyDrive = UUID('{A52BBA46-E9E1-435f-B3D9-28DAA648C0F6}') 121 | SkyDriveCameraRoll = UUID('{767E6811-49CB-4273-87C2-20F355E1085B}') 122 | SkyDriveDocuments = UUID('{24D89E24-2F19-4534-9DDE-6A6671FBB8FE}') 123 | SkyDrivePictures = UUID('{339719B5-8C47-4894-94C2-D8F77ADD44A6}') 124 | StartMenu = UUID('{625B53C3-AB48-4EC1-BA1F-A1EF4146FC19}') 125 | Startup = UUID('{B97D20BB-F46A-4C97-BA10-5E3608430854}') 126 | System = UUID('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}') 127 | SystemX86 = UUID('{D65231B0-B2F1-4857-A4CE-A8E7C6EA7D27}') 128 | Templates = UUID('{A63293E8-664E-48DB-A079-DF759E0509F7}') 129 | UserPinned = UUID('{9E3995AB-1F9C-4F13-B827-48B24B6C7174}') 130 | UserProfiles = UUID('{0762D272-C50A-4BB0-A382-697DCD729B80}') 131 | UserProgramFiles = UUID('{5CD7AEE2-2219-4A67-B85D-6C9CE15660CB}') 132 | UserProgramFilesCommon = UUID('{BCBD3057-CA5C-4622-B42D-BC56DB0AE516}') 133 | Videos = UUID('{18989B1D-99B5-455B-841C-AB7C74E4DDFC}') 134 | VideosLibrary = UUID('{491E922F-5643-4AF4-A7EB-4E7A138D8174}') 135 | Windows = UUID('{F38BF404-1D43-42F2-9305-67DE0B28FC23}') 136 | 137 | class UserHandle: # [3] 138 | current = wintypes.HANDLE(0) 139 | common = wintypes.HANDLE(-1) 140 | 141 | _CoTaskMemFree = windll.ole32.CoTaskMemFree # [4] 142 | _CoTaskMemFree.restype= None 143 | _CoTaskMemFree.argtypes = [ctypes.c_void_p] 144 | 145 | _SHGetKnownFolderPath = windll.shell32.SHGetKnownFolderPath # [5] [3] 146 | _SHGetKnownFolderPath.argtypes = [ 147 | ctypes.POINTER(GUID), wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p) 148 | ] 149 | 150 | class PathNotFoundException(Exception): pass 151 | 152 | def get_path(folderid, user_handle=UserHandle.common): 153 | fid = GUID(folderid) 154 | pPath = ctypes.c_wchar_p() 155 | S_OK = 0 156 | if _SHGetKnownFolderPath(ctypes.byref(fid), 0, user_handle, ctypes.byref(pPath)) != S_OK: 157 | raise PathNotFoundException() 158 | path = pPath.value 159 | _CoTaskMemFree(pPath) 160 | return path 161 | 162 | if __name__ == '__main__': 163 | if len(sys.argv) < 2 or sys.argv[1] in ['-?', '/?']: 164 | print('python knownpaths.py FOLDERID {current|common}') 165 | sys.exit(0) 166 | 167 | try: 168 | folderid = getattr(FOLDERID, sys.argv[1]) 169 | except AttributeError: 170 | print('Unknown folder id "%s"' % sys.argv[1], file=sys.stderr) 171 | sys.exit(1) 172 | 173 | try: 174 | if len(sys.argv) == 2: 175 | print(get_path(folderid)) 176 | else: 177 | print(get_path(folderid, getattr(UserHandle, sys.argv[2]))) 178 | except PathNotFoundException: 179 | print('Folder not found "%s"' % ' '.join(sys.argv[1:]), file=sys.stderr) 180 | sys.exit(1) 181 | 182 | # [1] http://msdn.microsoft.com/en-us/library/windows/desktop/aa373931.aspx 183 | # [2] http://msdn.microsoft.com/en-us/library/windows/desktop/dd378457.aspx 184 | # [3] http://msdn.microsoft.com/en-us/library/windows/desktop/bb762188.aspx 185 | # [4] http://msdn.microsoft.com/en-us/library/windows/desktop/ms680722.aspx 186 | # [5] http://www.themacaque.com/?p=954 -------------------------------------------------------------------------------- /src/pysweepme/PortManager.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2023 SweepMe! GmbH (sweep-me.net) 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 | 23 | from collections import OrderedDict 24 | 25 | from pysweepme.ErrorMessage import error, debug 26 | from pysweepme import Ports 27 | from pysweepme.FolderManager import getFoMa 28 | from pysweepme import Config 29 | from pysweepme.Ports import Port 30 | 31 | try: 32 | import clr # pythonnet for loading external DotNet DLLs 33 | except ModuleNotFoundError: 34 | error("Package clr/pythonnet not installed. Use 'pip install pythonnet' in command line.") 35 | except ImportError: 36 | error("Cannot import clr package. Please check whether your Microsoft .NET Framework is up to date.") 37 | 38 | 39 | class PortManager(object): 40 | 41 | _instance = None 42 | 43 | def __init__(self): 44 | 45 | if not hasattr(self, "initialized"): 46 | 47 | # Adding Prologix controllers 48 | ProgramConfig = Config.Config(getFoMa().get_file("CONFIG")) 49 | prologix_controller = ProgramConfig.getConfigOptions("PrologixController") 50 | for port in prologix_controller.values(): 51 | self.add_prologix_controller(port) 52 | 53 | # stores all available ports in a dictionary 54 | self._ports: OrderedDict[str, Port] = OrderedDict([]) 55 | 56 | self.initialized = True 57 | 58 | def __new__(cls, *args, **kwargs): 59 | # create singleton 60 | if not isinstance(cls._instance, cls): 61 | cls._instance = object.__new__(cls) 62 | return cls._instance 63 | 64 | def startup(self): 65 | """ function is called by SweepMe! """ 66 | pass 67 | 68 | def on_load_setting(self): 69 | """ function is called by SweepMe! """ 70 | self.clear_portmanager_dialog() 71 | 72 | def prepareRun(self): 73 | """ function is called by SweepMe! """ 74 | self.open_resourcemanager() 75 | 76 | def prepareStop(self): 77 | """ function is called by SweepMe! """ 78 | self.close_all_ports() 79 | self.close_resourcemanager() 80 | 81 | def clear_portmanager_dialog(self): 82 | """ to be overwritten by PortManagerDialog """ 83 | pass 84 | 85 | def get_resources_available(self, port_types, port_identification=[]): 86 | """ 87 | returns a list of resources for given port types 88 | Attention: port identification is not properly implemented. Only works for USBTMC and if identification was 89 | already retrieved beforehand 90 | 91 | Args: 92 | port_types: list of port types 93 | port_identification: list of identification strings 94 | 95 | Returns: 96 | List of resource strings 97 | """ 98 | # called by SweepMe! to get resources for GUI when using Find Ports 99 | # port_types is a list of Port types (string), e.g. ['COM', 'GPIB'] 100 | 101 | port_list = [] 102 | 103 | for port_type in port_types: 104 | 105 | if port_type == "USB": 106 | port_type = "USBTMC" 107 | 108 | if port_type in Ports.port_types: 109 | resources = Ports.port_types[port_type].find_resources() 110 | 111 | port_list += resources 112 | 113 | for port in self._ports: 114 | if self._ports[port].port_properties["type"] in port_types: 115 | self._ports[port].port_properties["active"] = False 116 | 117 | # removing all inactive ports 118 | ports_to_delete = [] 119 | 120 | for port in self._ports: 121 | if self._ports[port].port_properties["type"] in port_types: 122 | if self._ports[port].port_properties["active"] is False: 123 | ports_to_delete.append(port) 124 | 125 | for port in ports_to_delete: 126 | del self._ports[port] 127 | 128 | # list all active ports of appropriate type 129 | for port in self._ports: 130 | if self._ports[port].port_properties["type"] in port_types: 131 | 132 | if self._ports[port].port_properties["identification"] is not None and \ 133 | self._ports[port].port_properties["type"] in ["USB", "USBTMC"]: 134 | 135 | for identification_string in port_identification: 136 | if identification_string in self._ports[port].port_properties["identification"]: 137 | port_list.append(self._ports[port].port_properties["resource"]) 138 | break 139 | else: 140 | port_list.append(self._ports[port].port_properties["resource"]) 141 | 142 | return port_list 143 | 144 | def get_port(self, resource: str, properties={}): 145 | """ 146 | returns a pysweepme Port object that is opened 147 | 148 | Args: 149 | resource: str, name of resource to open, e.g., "COM1" 150 | properties: dictionary of with port properties 151 | 152 | Returns: 153 | pysweepme Port object 154 | """ 155 | 156 | # check whether properties actually exist 157 | # we have to check it for all possible port types that are supported so far 158 | all_port_properties = {} 159 | for port_type in Ports.port_types.values(): 160 | all_port_properties.update(port_type.properties) 161 | for key in properties: 162 | if key not in all_port_properties: 163 | debug("PortManager: property '%s' of port '%s' is unknown by any port type. Please check the " 164 | "wiki (F1) which keywords are supported." % (key, resource)) 165 | 166 | # the properties of the driver are overwritten by the properties of the port dialog 167 | # we add the port dialog properties after checking the use of proper keywords as the port dialog might introduce 168 | # some keywords like 'debug' that are not static properties of the port types 169 | portdialog_properties = self.get_port_properties_from_dialog(resource) 170 | properties.update(portdialog_properties) 171 | 172 | # depending on whether the port already exists or not, we have to create one or use the old one and refresh it. 173 | if resource not in self._ports: 174 | try: 175 | port = Ports.get_port(resource, properties) 176 | 177 | if port is False: 178 | debug("PortManager: port '%s' cannot be created. Please check the port troubleshooting " 179 | "guide in the wiki (F1)." % resource) 180 | return False 181 | else: 182 | self._ports[resource] = port 183 | 184 | except: 185 | error() 186 | return False 187 | 188 | else: 189 | # make sure the initial parameters are set, because the properties of the device class and the port dialog 190 | # should always be used on top of a fresh set of initialized parameters 191 | self._ports[resource].initialize_port_properties() 192 | self._ports[resource].update_properties(properties) 193 | 194 | # port is checked if being open and if not, port is opened 195 | self.open_port(resource) 196 | 197 | return self._ports[resource] 198 | 199 | def get_port_properties_from_dialog(self, resource): 200 | """ 201 | function can be overwritten by a dialog in SweepMe! to return custom port properties for a given resource 202 | that are overwrite the port properties of the driver 203 | Args: 204 | resource: str 205 | 206 | Returns: 207 | dict: port properties 208 | 209 | """ 210 | 211 | # no port properties are returned here as default 212 | # however, this changes when get_port_properties_from_dialog is overwritten by SweepMe! or any other app 213 | return {} 214 | 215 | def remove_port(self, resource): 216 | """ 217 | removes a port by resource name from the list of ports 218 | 219 | Args: 220 | resource: str, resource name, e.g. "COM1" 221 | 222 | Returns: 223 | None 224 | """ 225 | if resource in self._ports: 226 | del self._ports[resource] 227 | 228 | @staticmethod 229 | def find_resources(port_types=None): 230 | """ 231 | finds resources for given port types. If no port types are given, all possible port types are searched for 232 | resources 233 | Args: 234 | port_types: List of port types 235 | 236 | Returns: 237 | Dictionary containing a list of resource for each port type key 238 | """ 239 | 240 | # all ports if not types are not specified 241 | if port_types is None: 242 | port_types = Ports.port_types 243 | 244 | resources = {} 245 | for port_type in port_types: 246 | if port_type == "USB": 247 | port_type = "USBTMC" 248 | try: 249 | resources[port_type] = Ports.port_types[port_type].find_resources() 250 | except: 251 | error("Unable to find ports for %s." % port_type) 252 | 253 | return resources 254 | 255 | def get_port_types(self): 256 | """ 257 | Returns: 258 | List of port types supported by pysweepme.Ports 259 | """ 260 | return Ports.port_types.keys() 261 | 262 | def set_port_logging(self, resource, state): 263 | """ 264 | change logging state by resource name 265 | 266 | Args: 267 | resource: str, name of the resource such as "COM1" 268 | state: bool 269 | 270 | Returns: 271 | None 272 | 273 | """ 274 | if resource not in self._ports: 275 | self._ports[resource] = Ports.get_port(resource) 276 | 277 | self._ports[resource].set_logging(state) 278 | 279 | def get_identification(self, resource: str) -> str: 280 | """ 281 | returns identification string 282 | Args: 283 | resource: str, resource name, e.g. "GPIB0::1::INSTR" 284 | 285 | Returns: 286 | str -> Identification string 287 | 288 | """ 289 | if resource not in self._ports: 290 | self._ports[resource] = Ports.get_port(resource) 291 | 292 | self.open_port(resource) 293 | identification = self._ports[resource].get_identification() 294 | self._ports[resource].close() 295 | self.close_resourcemanager() 296 | return identification 297 | 298 | def open_port(self, resource: str) -> None: 299 | """ 300 | opens port by resource name 301 | Args: 302 | resource: str, name of resource e.g. "COM1" 303 | 304 | Returns: 305 | None 306 | """ 307 | if self._ports[resource].port_properties["open"] is False: 308 | self._ports[resource].open() 309 | 310 | if self._ports[resource].port_properties["clear"]: 311 | self._ports[resource].clear() 312 | 313 | def close_port(self, resource: str) -> None: 314 | """ 315 | closes port by resource name 316 | Args: 317 | resource: str, name of resource e.g. "COM1" 318 | 319 | Returns: 320 | None 321 | """ 322 | if self._ports[resource].port_properties["open"] is True: 323 | self._ports[resource].close() 324 | 325 | def open_resourcemanager(self) -> None: 326 | """ 327 | creates a VISA resource manager, forwards the method from pysweepme.Ports 328 | 329 | Returns: 330 | None 331 | """ 332 | Ports.get_resourcemanager() 333 | 334 | def close_resourcemanager(self) -> None: 335 | """ 336 | closes the VISA resource manager, forwards the method from pysweepme.Ports 337 | 338 | Returns: 339 | None 340 | """ 341 | Ports.close_resourcemanager() 342 | 343 | def is_resourcemanager(self): 344 | """ 345 | returns whether the VISA resource manager is created, forwards the method from pysweepme.Ports 346 | 347 | Returns: 348 | None 349 | """ 350 | return Ports.is_resourcemanager() 351 | 352 | def close_all_ports(self) -> None: 353 | """ 354 | closes all open ports 355 | 356 | Returns: 357 | 358 | """ 359 | 360 | for resource in self._ports: 361 | try: 362 | self.close_port(resource) 363 | except: 364 | error() 365 | 366 | def add_prologix_controller(self, port) -> None: 367 | """ 368 | adds a prologix controller by using a COM port 369 | Args: 370 | port: str, COM port 371 | 372 | Returns: 373 | None 374 | """ 375 | Ports.add_prologix_controller(port) 376 | 377 | def remove_prologix_controller(self, port) -> None: 378 | """ 379 | removes a prologix controller by using a COM port 380 | Args: 381 | port: str, COM port 382 | 383 | Returns: 384 | None 385 | """ 386 | Ports.remove_prologix_controller(port) 387 | -------------------------------------------------------------------------------- /src/pysweepme/FolderManager.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021 - 2022 SweepMe! GmbH 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 | 23 | from __future__ import annotations 24 | import os 25 | import sys 26 | import inspect 27 | from pathlib import Path 28 | from typing import Optional 29 | 30 | from .Architecture import version_info 31 | from .ErrorMessage import error, debug 32 | 33 | 34 | # global variable that saves a temporarily set path that could be added to PATH in case addFolderToPATH is called 35 | # the variable was introduced with SweepMe! 1.5.4 to set a path during loading Modules 36 | # as modules are not directly loaded 37 | TemporaryFolderForPATH: Optional[str] = None 38 | 39 | 40 | _FoMa: Optional[FolderManager] = None 41 | 42 | 43 | def _prepend_to_os_path(path_to_prepend: Path) -> None: 44 | if str(path_to_prepend) not in os.environ["PATH"].split(os.pathsep): 45 | os.environ["PATH"] = str(path_to_prepend) + os.pathsep + os.environ["PATH"] 46 | 47 | 48 | def _prepend_to_sys_path(path_to_prepend: Path) -> None: 49 | if str(path_to_prepend) not in sys.path: 50 | sys.path = [str(path_to_prepend), *sys.path] 51 | 52 | 53 | def _add_libs_dirs_to_path(libs_path: Path) -> None: 54 | # Only the main folder is only added to sys.path, as adding subfolders 55 | # leads to problems with the import of submodules that have the 56 | # same name as the main package. 57 | # Only folders that actually contain dll files are added to the 58 | # environment variable PATH, because it has a limit of 32767 characters 59 | # which would be exceeded quickly otherwise. 60 | if not libs_path.is_dir(): 61 | return 62 | _prepend_to_sys_path(libs_path) 63 | # add also library.zip in libs 64 | if (libs_path / "library.zip").exists(): 65 | _prepend_to_sys_path(libs_path / "library.zip") 66 | 67 | # Find all dll files and then take the directory they are in 68 | dll_folders = {dll_file.parent for dll_file in libs_path.rglob("*.dll") if dll_file.is_file()} 69 | for dll_folder in dll_folders: 70 | _prepend_to_os_path(dll_folder) 71 | 72 | 73 | def addFolderToPATH(path_to_add: str = "") -> bool: 74 | """Add libraries folder of calling script to the PATH. 75 | 76 | Used by DeviceClasses and CustomFunctions to add their path to PATH. 77 | If no argument is given, the path of the calling file is used. 78 | """ 79 | if not path_to_add: 80 | main_file = inspect.stack()[1][1] 81 | main_path = Path(main_file).resolve().parent.absolute() 82 | elif Path(path_to_add).exists(): 83 | main_path = Path(path_to_add).resolve().absolute() 84 | else: 85 | return False 86 | 87 | _prepend_to_sys_path(main_path) 88 | # Only add the main path to the OS PATH if it actually contains a dll. 89 | # Although this should not happen, as dll's should be in the libs 32bit or 64bit directories. 90 | # We use any(...) so that the glob Generator can stop when the first dll is found. 91 | if any(main_path.glob("*.dll")): 92 | _prepend_to_os_path(main_path) 93 | 94 | libs_paths = [ 95 | main_path / "libraries" / f"libs_{version_info.python_suffix}", # architecture specific 96 | main_path / "libraries" / "libs_common", # common 97 | ] 98 | 99 | if not any(libs_path.is_dir() for libs_path in libs_paths): 100 | # if there is no libs directory in the new libraries folder, then use old libs folder as fallback 101 | libs_paths = [main_path / "libs"] 102 | 103 | for libs_path in libs_paths: 104 | _add_libs_dirs_to_path(libs_path) 105 | 106 | return True 107 | 108 | 109 | def addModuleFolderToPATH(path_to_add: str = "") -> bool: 110 | """Add libraries folder of calling module to the PATH. 111 | 112 | Used by Modules to add their path to PATH. 113 | If no argument is given, the path of the calling Module is used. 114 | """ 115 | if path_to_add: 116 | if Path(path_to_add).exists(): 117 | main_path = path_to_add 118 | else: 119 | return False 120 | 121 | elif TemporaryFolderForPATH is not None and Path(TemporaryFolderForPATH).exists(): 122 | main_path = TemporaryFolderForPATH 123 | 124 | else: 125 | main_file = inspect.stack()[1][1] 126 | main_path = str(Path(main_file).resolve().parent.absolute()) 127 | 128 | return addFolderToPATH(main_path) 129 | 130 | 131 | def setTemporaryFolderForPATH(path_to_set): 132 | global TemporaryFolderForPATH 133 | TemporaryFolderForPATH = path_to_set 134 | 135 | 136 | def unsetTemporaryFolderForPATH(): 137 | global TemporaryFolderForPATH 138 | TemporaryFolderForPATH = None 139 | 140 | 141 | def get_path(identifier): 142 | """ returns a path for a given identifier, such as 'CUSTOMDEVICES', 'DEVICES', ... """ 143 | FoMa = getFoMa() 144 | if identifier in FoMa.folders: 145 | return FoMa.get_path(identifier) 146 | else: 147 | debug("FolderManager: Folder %s unknown" % identifier) 148 | return False 149 | 150 | 151 | def set_path(identifier, path): 152 | """ sets a path for a given identifier, such as 'CUSTOMDEVICES', 'DEVICES', ... """ 153 | FoMa = getFoMa() 154 | if identifier in FoMa.folders: 155 | FoMa.set_path(identifier, path) 156 | else: 157 | debug("FolderManager: Folder %s unknown" % identifier) 158 | return False 159 | 160 | 161 | def get_file(identifier): 162 | FoMa = getFoMa() 163 | if identifier in FoMa.files: 164 | return FoMa.get_file(identifier) 165 | else: 166 | debug("FolderManager: File %s unknown" % identifier) 167 | return False 168 | 169 | 170 | def set_file(identifier, path): 171 | FoMa = getFoMa() 172 | if identifier in FoMa.files: 173 | return FoMa.set_file(identifier, path) 174 | 175 | 176 | # remains for compatibility 177 | def main_is_frozen(): 178 | return is_main_frozen() 179 | 180 | def is_main_frozen(): 181 | return hasattr(sys, "frozen") 182 | 183 | 184 | # This class allows to create multiple instances of the folder manager. This is only required in 185 | # rare cases, like getting a folder manager instance that corresponds to the folders of a different 186 | # process with another instance_id and other paths for the writable objects. 187 | class FolderManagerInstance(object): 188 | 189 | def __init__(self, create=False, instance_id=None): 190 | """create defines whether folders are created. When used with pysweepme, the default will not create folders""" 191 | 192 | # this ensures that the FolderManager can be called multiple times without performing __init__ every time 193 | if not hasattr(self, "_is_init_complete"): 194 | self._is_init_complete = True 195 | 196 | self._instance_id = instance_id 197 | if instance_id is not None: 198 | # we do not only use the pure id but rather "instance" + the id. 199 | # this simplifies the identification of folders and files that have been created 200 | # by additional instances. 201 | # Without the string "instance" globbing for instances of the debug.log like "debug_*.log" would 202 | # erroneously return debug_fh.log as well. 203 | self._instance_suffix = "_instance" + instance_id 204 | else: 205 | self._instance_suffix = "" 206 | 207 | # define variables for all folders 208 | self.mainpath = self.get_main_dir() 209 | 210 | mainpath_files = os.listdir(self.mainpath) 211 | 212 | self.is_sweepme_executable = "SweepMe!.exe" in mainpath_files and self.main_is_frozen() 213 | self.is_portable_mode = not "installed.ini" in mainpath_files 214 | 215 | if sys.platform == "win32": 216 | 217 | from . import WinFolder # also needed for portable mode 218 | 219 | self.publicpath = os.path.join(WinFolder.get_path( WinFolder.FOLDERID.PublicDocuments ), 'SweepMe!' ) 220 | self.roamingpath = os.path.join(WinFolder.get_path(WinFolder.FOLDERID.RoamingAppData, WinFolder.UserHandle.current ), 'SweepMe!' ) 221 | self.localpath = os.path.join( WinFolder.get_path(WinFolder.FOLDERID.LocalAppData, WinFolder.UserHandle.current ), 'SweepMe!' ) 222 | self.programdatapath = os.path.join( WinFolder.get_path(WinFolder.FOLDERID.ProgramData), 'SweepMe!' ) 223 | self.programdatapath_variable = os.path.join( WinFolder.get_path(WinFolder.FOLDERID.ProgramData), 'SweepMe!' ) 224 | 225 | self.tempfolder = self.localpath + os.sep + f'temp{self._instance_suffix}' 226 | 227 | 228 | if self.is_sweepme_executable and self.is_portable_mode: # portable mode -> we overwrite the default paths 229 | 230 | self.portable_data_path = os.path.dirname(self.mainpath) + os.sep + "SweepMe! user data" 231 | 232 | if self.is_sweepme_executable: # otherwise a folder is created if pysweepme is used standalone 233 | if not os.path.exists(self.portable_data_path): 234 | os.mkdir(self.portable_data_path) 235 | 236 | self.publicpath = self.portable_data_path + os.sep + "public" 237 | self.roamingpath = self.portable_data_path + os.sep + "roaming" 238 | self.localpath = self.portable_data_path + os.sep + "local" 239 | self.programdatapath = os.path.join(WinFolder.get_path(WinFolder.FOLDERID.ProgramData), 'SweepMe!') 240 | self.programdatapath_variable = self.portable_data_path + os.sep + "programdata" 241 | 242 | self.tempfolder = ( 243 | WinFolder.get_path(WinFolder.FOLDERID.LocalAppData, WinFolder.UserHandle.current) 244 | + os.sep + 'SweepMe!' + os.sep + 'temp{self._instance_suffix}' 245 | ) 246 | 247 | elif sys.platform.startswith("linux"): 248 | ## not defined yet, tbd 249 | self.publicpath = "." 250 | self.roamingpath = "." 251 | self.localpath = "." 252 | self.programdatapath = "." 253 | 254 | 255 | self.libsfolder = self.mainpath + os.sep + "libs" 256 | 257 | self.resourcesfolder = self.mainpath + os.sep + "resources" 258 | 259 | self.configfolder = self.programdatapath_variable + os.sep + "configuration" 260 | self.serverfolder = self.configfolder + os.sep + "server" 261 | self.profilesfolder = self.roamingpath + os.sep + "profiles" 262 | self.SweepMeIcon = self.resourcesfolder + os.sep + "icons" + os.sep + "SweepMeS_icon.ico" 263 | self.settingfolder = self.publicpath + os.sep + "Settings" 264 | self.roamingsetting = self.roamingpath + os.sep + "Settings" 265 | self.examplesfolder = self.mainpath + os.sep + "examples" 266 | self.measurementfolder = self.publicpath + os.sep + "Measurement" 267 | self.DCfolder = self.mainpath + os.sep + "Devices" 268 | self.shareddevicesfolder = self.programdatapath_variable + os.sep + "Devices" 269 | self.versionsfolder = self.programdatapath_variable + os.sep + "Versions" 270 | self.modulesfolder = self.mainpath + os.sep + "Modules" 271 | self.sharedmodulesfolder = self.programdatapath_variable + os.sep + "Modules" 272 | self.sweepscriptfolder = self.publicpath + os.sep + "SweepScripts" 273 | self.pythonscriptsfolder = self.publicpath + os.sep + "Tools" + os.sep + "PythonScripts" 274 | self.extlibsfolder = self.publicpath + os.sep + "ExternalLibraries" 275 | self.customfolder = self.publicpath + os.sep + "CustomFiles" 276 | self.calibrationfolder = self.publicpath + os.sep + "CalibrationFiles" 277 | self.customDCfolderold = self.publicpath + os.sep + "CustomDeviceClasses" 278 | self.customDCfolder = self.publicpath + os.sep + "CustomDevices" 279 | self.customMCfolder = self.publicpath + os.sep + "CustomModules" 280 | self.DCDatafolder = self.publicpath + os.sep + "DataDevices" 281 | self.MCDatafolder = self.publicpath + os.sep + "DataModules" 282 | self.screenshotfolder = self.publicpath + os.sep + "Screenshots" 283 | self.interfacesfolder = self.mainpath + os.sep + "libs" + os.sep + "interfaces" 284 | self.widgetsfolder = self.mainpath + os.sep + "Widgets" 285 | self.customresourcesfolder = self.publicpath + os.sep + "Resources" 286 | self.customcolormapsfolder = self.customresourcesfolder + os.sep + "colormaps" 287 | self.customstylesfolder = self.customresourcesfolder + os.sep + "styles" 288 | self.customiconsfolder = self.customresourcesfolder + os.sep + "icons" 289 | 290 | self.folders = { 291 | "MAIN": self.mainpath, # Folder where SweepMe!.exe is 292 | "TEMP": self.tempfolder, # temporary measurement data in MAIN 293 | "RESOURCES": self.resourcesfolder, # Folder insider MAIN with icon, colormaps, etc. 294 | "DATA": self.measurementfolder, # Measurement data in PUBLIC 295 | "SETTINGS": self.settingfolder, # Settings in PUBLIC 296 | 297 | "ROAMINGSETTINGS": self.roamingsetting, # Settings in ROAMING 298 | "EXAMPLES": self.examplesfolder, # Example settings in MAIN 299 | "PROFILES": self.profilesfolder, # Profile inis in ROAMING 300 | 301 | "PROGRAMDATA": self.programdatapath, # ProgramData path for things that need be accessed by all user but should not be seen easily 302 | 303 | "DEVICES": self.DCfolder, # Devices in MAIN 304 | "MODULES": self.modulesfolder, # Modules in MAIN 305 | 306 | "WIDGETS": self.widgetsfolder, # WIDGETS in MAIN 307 | "INTERFACES": self.interfacesfolder, # INTERFACES in MAIN\libs 308 | 309 | "SHAREDDEVICES": self.shareddevicesfolder, # Devices in ProgramData 310 | "SHAREDMODULES": self.sharedmodulesfolder, # Modules in ProgramData 311 | "VERSIONS": self.versionsfolder, # Versions in ProgramData 312 | 313 | "CONFIG": self.configfolder, # Config folder in ProgramData 314 | "SERVER": self.serverfolder, # Server folder in ProgramData / Config folder 315 | 316 | "CUSTOMDEVICESOLD": self.customDCfolderold,# DeviceClasses in PUBLIC 317 | "CUSTOMDEVICES": self.customDCfolder, # Devices in PUBLIC 318 | "CUSTOMMODULES": self.customMCfolder, # Modules in PUBLIC 319 | 320 | "DATAMODULES": self.MCDatafolder, # Folder for Module specific data in PUBLIC 321 | "DATADEVICES": self.DCDatafolder, # Folder for Device specific data in PUBLIC 322 | 323 | "SCREENSHOTS": self.screenshotfolder, # Folder for screenshots in PUBLIC 324 | 325 | "LOCAL": self.localpath, # Local windows user appdata 326 | "ROAMING": self.roamingpath, # Roaming windows user appdata 327 | "PUBLIC": self.publicpath, # Public documents folder for SweepMe! 328 | # "SWEEPSCRIPTS": self.sweepscriptfolder, # Sweep script folder in PUBLIC 329 | "PYTHONSCRIPTS": self.pythonscriptsfolder, 330 | "CALIBRATIONS": self.calibrationfolder, # Calibration folder in PUBLIC 331 | "CUSTOM": self.customfolder, # Custom files in PUBLIC 332 | "CUSTOMFILES": self.customfolder, # Custom files in PUBLIC 333 | "CUSTOMRESOURCES": self.customresourcesfolder, # Custom resources in PUBLIC 334 | "CUSTOMCOLORMAPS": self.customcolormapsfolder, # Custom colormaps in PUBLIC 335 | "CUSTOMSTYLES": self.customstylesfolder, # Custom styles in PUBLIC 336 | "CUSTOMICONS": self.customiconsfolder, # Custom icons in PUBLIC 337 | 338 | 339 | 340 | # "SYSTEMUSER": self.systemuserpath, # SweepMe! folder in system user folder 341 | "EXTLIBS": self.extlibsfolder, # External libraries such as dll in PUBLIC 342 | } 343 | 344 | self.profileuserfile = None # because we do not know which profile will be selected 345 | self.systemuserfile = self.roamingpath + os.sep + 'OSuser.ini' 346 | self.userfile = self.mainpath + os.sep + 'user.txt' 347 | self.configfile = self.configfolder + os.sep + 'config.ini' 348 | self.texteditor = self.libsfolder + os.sep + "Pnotepad" + os.sep + "pn.exe" 349 | self.logbookfile = self.tempfolder + os.sep + "temp_logbook.txt" 350 | self.debugfile = self.publicpath + os.sep + f"debug{self._instance_suffix}.log" 351 | self.debugfhfile = self.publicpath + os.sep + f"debug_fh{self._instance_suffix}.log" 352 | 353 | # print("FolderManager: self.files redefined") 354 | self.files = { 355 | "PROFILEINI": self.profileuserfile, # Configuration ini of Profile 356 | "OSUSERINI": self.systemuserfile, # System user config file in ROAMING 357 | "CONFIG": self.configfile, # Configuration folder in MAIN 358 | 359 | "SWEEPMEICON":self.SweepMeIcon, # SweepMe! icon in resources 360 | "TEXTEDITOR": self.texteditor, # Texteditor 361 | "LOGBOOK": self.logbookfile, # Logbook file in tempfolder 362 | "DEBUG": self.debugfile, # debug file in public folder 363 | "DEBUGFH": self.debugfhfile, # debug faulthandler file in public folder 364 | } 365 | 366 | if create: 367 | self.create_folders() 368 | 369 | def create_folders(self): 370 | 371 | for folder in [self.publicpath, self.roamingpath, self.localpath, self.programdatapath_variable]: 372 | 373 | # Important: We don't want to make Progamdata folder here as this would result in a folder that can only be written 374 | # by the user that created it first time. 375 | # we use os.path.abspath to make sure the folder format is the same 376 | if not os.path.abspath(folder) in os.path.abspath(self.folders["PROGRAMDATA"]): 377 | if not os.path.exists(folder): 378 | os.mkdir(folder) 379 | 380 | 381 | ### add path if they do not exist 382 | for key in self.folders: 383 | 384 | # This folder is not used anymore and should not be used anymore 385 | if key == "CUSTOMDEVICESOLD": 386 | continue 387 | 388 | # Important: We don't want to make Progamdata folder here as this would result in a folder that can only be written 389 | # by the user that created it first time. 390 | elif key == "PROGRAMDATA": 391 | continue 392 | 393 | if not os.path.exists(self.folders[key]): 394 | try: 395 | os.makedirs(self.folders[key]) 396 | except: 397 | error() 398 | 399 | if not self.folders[key] in sys.path: 400 | sys.path.append(self.folders[key]) 401 | 402 | if not self.folders[key] in os.environ["PATH"].split(os.pathsep): 403 | os.environ["PATH"] += os.pathsep + self.folders[key] 404 | 405 | if key == "EXTLIBS": 406 | 407 | for root, _dirs, _files in os.walk(self.folders[key], topdown=True): 408 | 409 | if not root in sys.path: 410 | sys.path.append(root) 411 | 412 | if not root in os.environ["PATH"].split(os.pathsep): 413 | os.environ["PATH"] += os.pathsep + root 414 | 415 | 416 | 417 | def get_path(self, identifier): 418 | 419 | if identifier in self.folders: 420 | if not os.path.exists(self.folders[identifier]): 421 | try: 422 | os.mkdir(self.folders[identifier]) 423 | except: 424 | pass 425 | 426 | return self.folders[identifier] 427 | 428 | else: 429 | debug("FolderManager: Folder %s unknown" % identifier) 430 | return False 431 | 432 | 433 | def set_path(self, identifier, path): 434 | 435 | if identifier in self.folders: 436 | self.folders[identifier] = path 437 | else: 438 | debug("FolderManager: identifier '%s' unknown to set path" % identifier) 439 | 440 | def get_file(self, identifier): 441 | 442 | # print() 443 | # print("get_file") 444 | # print (identifier) 445 | # print (self.files) 446 | 447 | if identifier in self.files: 448 | return self.files[identifier] 449 | else: 450 | debug("FolderManager: File %s unknown" % identifier) 451 | return False 452 | 453 | def set_file(self, identifier, path): 454 | 455 | if identifier in self.files: 456 | self.files[identifier] = path 457 | else: 458 | debug("FolderManager: identifier '%s' unknown to set file" % identifier) 459 | 460 | # print() 461 | # print("set_file") 462 | # print (identifier) 463 | # print (self.files) 464 | 465 | 466 | def get_main_dir(self): 467 | if self.main_is_frozen(): 468 | return os.path.dirname(sys.executable) 469 | 470 | return os.getcwd() 471 | 472 | def main_is_frozen(self): 473 | return self.is_main_frozen() 474 | 475 | def is_main_frozen(self): 476 | return hasattr(sys, "frozen") 477 | 478 | 479 | # Singleton Wrapper around the FolderManager, as one usually only wants to use one instance of the FolderManager 480 | # throughout the whole application. 481 | class FolderManager(FolderManagerInstance): 482 | # If multiple instances of the same application are running, all but the first instance should get a unique 483 | # instance_id that is added to certain paths like the measurement folder or the debug.log file to avoid write 484 | # conflits 485 | _process_instance_id: Optional[str] = None 486 | _instance: Optional[FolderManager] = None 487 | 488 | def __init__(self, create=False): 489 | super().__init__(create, instance_id=self._process_instance_id) 490 | 491 | def __new__(cls, *args, **kwargs): 492 | # this ensures that the FolderManager can be called multiple times without creating a new instance 493 | if not isinstance(cls._instance, cls): 494 | cls._instance = super().__new__(cls) 495 | 496 | return cls._instance 497 | 498 | @classmethod 499 | def has_instance(cls) -> bool: 500 | return cls._instance is not None 501 | 502 | @classmethod 503 | def set_instance_id(cls, instance_id: str): 504 | if not instance_id: 505 | raise Exception("An instance id must be provided") 506 | if cls._process_instance_id is not None: 507 | raise Exception("The instance id has already been set and cannot be overwritten") 508 | if cls.has_instance(): 509 | raise Exception("The instance_id cannot be set after the FolderManager has already been initialized") 510 | cls._process_instance_id = instance_id 511 | 512 | @classmethod 513 | def get_instance_id(cls) -> Optional[str]: 514 | return cls._process_instance_id 515 | 516 | 517 | # The FolderManager is already required within pysweepme itself, or even the FolderManager module. 518 | # But we do not want to initialize the FolderManager already when importing, but only when it is used. 519 | # This function is mainly for pysweepme itself, as application code which is using pysweepme can also 520 | # use FolderManager() directly (which is a singleton). 521 | def getFoMa(): 522 | global _FoMa 523 | if _FoMa is None: 524 | _FoMa = FolderManager() 525 | return _FoMa 526 | -------------------------------------------------------------------------------- /src/pysweepme/EmptyDeviceClass.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | 23 | 24 | from __future__ import annotations 25 | 26 | import contextlib 27 | import functools 28 | import inspect 29 | import os 30 | from configparser import ConfigParser 31 | from copy import deepcopy 32 | from typing import TYPE_CHECKING, Any, ClassVar 33 | 34 | from pysweepme._utils import deprecated 35 | from pysweepme.UserInterface import message_balloon, message_box, message_info, message_log 36 | 37 | from .FolderManager import getFoMa 38 | 39 | _config = ConfigParser() 40 | 41 | 42 | class EmptyDevice: 43 | actions: list[ 44 | str 45 | ] = [] # static variable that can be used in a driver to define a list of function names that can be used as action 46 | 47 | _device_communication: ClassVar[dict[str, Any]] = {} 48 | _parameter_store: ClassVar[dict[str, Any]] = {} 49 | 50 | def __init__(self) -> None: 51 | self.variables: list[str] = [] 52 | self.units: list[str] = [] 53 | self.plottype: list[bool] = [] # True if plotted 54 | self.savetype: list[bool] = [] # True if saved 55 | 56 | self.shortname = "" 57 | self.idlevalue = None # deprecated, remains for compatibility reasons 58 | self.stopvalue = None # deprecated, remains for compatibility reasons 59 | self.value = None 60 | 61 | self.abort = "" # deprecated, remains for compatibility reasons 62 | self.stopMeasurement = "" # deprecated, remains for compatibility reasons, use raise Exception(...) instead 63 | 64 | # variable that can be overwritten by SweepMe! to indicate that the user requested a stop 65 | self._is_run_stopped = False 66 | 67 | self.port_manager = False 68 | self.port_types: list[str] = [] 69 | self.port_identifications: list[str] = [""] 70 | self.port_properties: dict[str, Any] = {} 71 | 72 | self.DeviceClassName = str(self.__class__)[8:-2].split(".")[0] 73 | 74 | # deprecated, remains for compatibility reasons 75 | # one should always ask the FolderManager regarding the actual path 76 | self.tempfolder = self.get_folder("TEMP") 77 | 78 | self._latest_parameters: dict[str, Any] | None = None 79 | 80 | def is_function_overwritten(self, function: str) -> bool: 81 | """Test if a given function is overwritten in a child class of EmptyDevice. 82 | 83 | Args: 84 | function: The name of the function to check. 85 | 86 | Returns: 87 | True, if the function is overwritten in a child class, False otherwise. 88 | """ 89 | overwrites = False 90 | # Get the class and a list of all base classes in the order python would resolve functions 91 | # using getmro(). If function is element of the classes dictionary and a function, BEFORE looking into 92 | # the EmptyDevice class, then function was obviously overridden by a subclass. 93 | for cls in inspect.getmro(self.__class__): 94 | if cls is EmptyDevice: 95 | break 96 | if function in cls.__dict__ and callable(getattr(cls, function, None)): 97 | overwrites = True 98 | break 99 | 100 | return overwrites 101 | 102 | @functools.cached_property 103 | def uses_update_gui_parameters(self) -> bool: 104 | """Boolean that tells if the driver uses the new update_gui_parameters function.""" 105 | return self.is_function_overwritten(self.update_gui_parameters.__name__) 106 | 107 | @property 108 | def device_communication(self) -> dict[str, Any]: 109 | """Single (global) dictionary where drivers can store their information that can be shared across instances.""" 110 | return EmptyDevice._device_communication 111 | 112 | @device_communication.setter 113 | def device_communication(self, _: object) -> None: 114 | msg = ( 115 | "Changing the device_communication dictionary is not allowed.\n" 116 | "Please only work on specific indices, e.g. \n" 117 | ">>> self.device_communication[] = " 118 | ) 119 | raise TypeError(msg) 120 | 121 | @staticmethod 122 | def clear_device_communication() -> None: 123 | """Clear all information that have been stored in the device_communication dictionary.""" 124 | EmptyDevice._device_communication = {} 125 | 126 | def list_functions(self): 127 | """Returns a list of all function names that are individually defined by the driver, e.g. get/set functions. 128 | 129 | These functions can be be used in python projects when using pysweepme to directly access instrument properties. 130 | """ 131 | all_functions = [func for func in dir(self) if callable(getattr(self, func)) and not func.startswith("_")] 132 | empty_device_functions = [func for func in dir(EmptyDevice) if callable(getattr(EmptyDevice, func))] 133 | 134 | return list(set(all_functions) - set(empty_device_functions)) 135 | 136 | def store_parameter(self, key: str, value: object) -> None: 137 | """Stores a value in the ParameterStore for a given key. 138 | 139 | Drivers can use the ParameterStore to store information and restore the same information later even in 140 | a new instance. 141 | 142 | Args: 143 | key: The key under which the information is stored. It should be unique and not conflicting with 144 | other drivers. 145 | value: The information to be stored. 146 | """ 147 | self._parameter_store[key] = value 148 | 149 | def restore_parameter(self, key: str) -> Any: # noqa: ANN401 # The type of the information is up to the user 150 | """Restores a parameter from the ParameterStore for a given key. 151 | 152 | Args: 153 | key: The key under which the information was stored before. 154 | 155 | Returns: 156 | The stored information, or None if no information can be found under the given key. 157 | """ 158 | if key in self._parameter_store: 159 | return self._parameter_store[key] 160 | return None 161 | 162 | def _on_run(self): 163 | """Called by SweepMe! at the beginning of the run to indicate the start. Do not overwrite it.""" 164 | self._is_run_stopped = False 165 | 166 | def _on_stop(self): 167 | """Called by SweepMe! in case the user requests a stop of the run. Do not overwrite it.""" 168 | self._is_run_stopped = True 169 | 170 | def is_run_stopped(self): 171 | """This function can be used in a driver to figure out whether a stop of the run was requested by the user 172 | It is helpful if a driver function is caught in a while-loop. 173 | """ 174 | return self._is_run_stopped 175 | 176 | @deprecated("1.5.8", "Use get_folder() instead.") 177 | def get_Folder(self, identifier): 178 | """Easy access to a folder without the need to import the FolderManager.""" 179 | return self.get_folder(identifier) 180 | 181 | def get_folder(self, identifier): 182 | """Easy access to a folder without the need to import the FolderManager.""" 183 | if identifier == "SELF": 184 | return os.path.abspath(os.path.dirname(inspect.getfile(self.__class__))) 185 | return getFoMa().get_path(identifier) 186 | 187 | @deprecated("1.5.8", "Use is_configfile() instead.") 188 | def isConfigFile(self): 189 | """deprecated: remains for compatibility reasons.""" 190 | return self.is_configfile() 191 | 192 | def is_configfile(self): 193 | """This function checks whether a driver related config file exists.""" 194 | # if config file directory is changed it must also be changed in version manager! 195 | if os.path.isfile(getFoMa().get_path("CUSTOMFILES") + os.sep + self.DeviceClassName + ".ini"): 196 | _config.read(getFoMa().get_path("CUSTOMFILES") + os.sep + self.DeviceClassName + ".ini") 197 | return True 198 | return False 199 | 200 | @deprecated("1.5.8", "Use get_configsections() instead.") 201 | def getConfigSections(self): 202 | """deprecated: remains for compatibility reasons.""" 203 | return self.get_configsections() 204 | 205 | def get_configsections(self): 206 | """This function returns all sections of the driver related config file. 207 | 208 | If not file exists, an empty list is returned. 209 | 210 | Returns: 211 | List of Strings 212 | """ 213 | if self.is_configfile(): 214 | return _config.sections() 215 | return [] 216 | 217 | @deprecated("1.5.8", "Use get_configoptions() instead.") 218 | def getConfigOptions(self, section): 219 | """deprecated: remains for compatibility reasons.""" 220 | return self.get_configoptions(section) 221 | 222 | def get_configoptions(self, section): 223 | """This functions returns all key-value options of a given section of the driver related config file. 224 | 225 | If the file does not exist, an empty dictionary is returned. 226 | 227 | Args: 228 | section: str, a config file section 229 | 230 | Returns: 231 | dict with pairs of key-value options 232 | """ 233 | vals = {} 234 | if self.is_configfile() and section in _config: 235 | for key in _config[section]: 236 | vals[key] = _config[section][key] 237 | return vals 238 | 239 | @deprecated("1.5.8", "Use get_config() instead.") 240 | def getConfig(self): 241 | """deprecated: remains for compatibility reasons.""" 242 | return self.get_config() 243 | 244 | def get_config(self): 245 | """This function returns a representation of the driver related config file by means of a nested dictionary 246 | that contains for each section a dictionary with the options. 247 | """ 248 | return {section: self.get_configoptions(section) for section in self.get_configsections()} 249 | 250 | def get_GUIparameter(self, parameter: dict[str, Any]): 251 | """Is overwritten by Device Class to retrieve the GUI parameter selected by the user.""" 252 | # Used for compatibility with old code that still uses get_GUIparameter 253 | if self.uses_update_gui_parameters: 254 | if parameter: 255 | self.apply_gui_parameters(parameter) 256 | self.update_gui_parameters(parameter) 257 | 258 | def set_GUIparameter(self) -> dict[str, Any]: 259 | """Is overwritten by Device Class to set the GUI parameter a user can select.""" 260 | # Used for compatibility with old code that still uses set_GUIparameter 261 | if self.uses_update_gui_parameters: 262 | return self.update_gui_parameters({}) 263 | return {} 264 | 265 | def get_gui_parameters_and_default_values(self) -> dict[str, Any]: 266 | """Get the default parameters and values of the driver. 267 | 268 | The default values explicitly mean a single value that shall be applied by default, 269 | e.g. in case of lists the first element. This value can thus directly be used as the 270 | default to show in the GUI, or as a fallback value for the driver when the value from 271 | the GUI was None. 272 | 273 | Returns: 274 | A mapping of parameter names to their default value. 275 | """ 276 | if self.uses_update_gui_parameters: 277 | driver_parameters = self.update_gui_parameters({}) 278 | else: 279 | driver_parameters = self.set_GUIparameter() 280 | return {k: v[0] if isinstance(v, list) and len(v) > 0 else v 281 | for k, v in driver_parameters.items()} 282 | 283 | def enhance_parameters_with_defaults(self, parameters: dict[str, Any] | None) -> dict[str, Any]: 284 | """Enhance the parameters dictionary with values from the driver's default parameter values. 285 | 286 | Any parameters in the default driver parameters that are not included in the passed dictionary or which are 287 | None, are set to the value given by the drivers default parameters. 288 | 289 | Args: 290 | parameters: The dictionary mapping parameters to their values from the GUI, or None. 291 | 292 | Returns: 293 | A mapping of parameters to their values from the GUI with default values as fallback. 294 | """ 295 | default_parameters = self.get_gui_parameters_and_default_values() 296 | if parameters is None: 297 | return default_parameters 298 | return default_parameters | {k: v for k, v in parameters.items() if v is not None} 299 | 300 | def update_gui_parameters_with_fallback( 301 | self, reading_mode: bool, parameters: dict[str, Any] | None = None, 302 | ) -> dict[str, Any]: 303 | """Update the driver's current parameters with the given values and return the parameters. 304 | 305 | This is a helper function that ensures compatibility with simple drivers. When the GUI parameters 306 | passed to the update_gui_parameters function don't cover all parameters required by simple drivers, 307 | the driver might raise an Exception because a required key is not found. This helper solves the issue 308 | by enhancing any missing GUI parameter with the respective default of the driver. 309 | For advanced drivers, this helper may also add fields, but the advanced driver should be intelligent 310 | enough to only extract the values that are required. 311 | Additionally, None-Type parameters (e.g. from the Parameter Syntax) are replaced with the defaults 312 | as well. 313 | 314 | Drivers should not overwrite this function. 315 | 316 | Args: 317 | reading_mode: When True, the purpose of this call is to get the default parameters of the driver. 318 | This means this function should call set_GUIparameter for old drivers, and 319 | update_gui_parameters for new drivers. 320 | When False, the purpose of this call is to apply the parameters passed to this function 321 | to the driver. This means this function should call get_GUIparameter for old drivers, and 322 | update_gui_parameters for new drivers. 323 | parameters: A dictionary where keys correspond to the GUI parameter name and the value is 324 | the value as specified in the GUI. 325 | When parameters is None, nothing shall be updated and instead only the defaults shall 326 | be returned. 327 | 328 | Returns: 329 | A dictionary where the keys are the fields that shall be shown in the GUI and the values are 330 | the default value. Simple drivers will always return the same defaults. 331 | """ 332 | # Note to developers: 333 | # The "enhance with defaults" is necessary, because when switching the driver in SweepMe!, 334 | # SweepMe! will pass the GUI parameters of the previous driver to the new driver, which 335 | # obviously doesn't match. Once this behaviour is fixed, this function won't be needed any longer. 336 | # This behaviour does not impact the correctness of the operation, as a second call to update the 337 | # GUI parameters will pass the correct parameters to the driver anyway. 338 | if not self.uses_update_gui_parameters: 339 | if reading_mode: 340 | return self.set_GUIparameter() # set_GUIparameter will actually get the parameters from the driver 341 | enhanced_parameters = self.enhance_parameters_with_defaults(parameters) 342 | self.get_GUIparameter(enhanced_parameters) # get_GUIparameter will actually apply the parameters 343 | return {} 344 | 345 | if parameters is None: 346 | return self.update_gui_parameters({}) 347 | enhanced_parameters = self.enhance_parameters_with_defaults(parameters) 348 | if enhanced_parameters: 349 | self.apply_gui_parameters(enhanced_parameters) 350 | return self.update_gui_parameters(enhanced_parameters) 351 | 352 | def apply_gui_parameters(self, parameters: dict[str, Any]) -> None: 353 | """Apply the given parameters to the driver instance. 354 | 355 | Args: 356 | parameters: A dictionary where keys correspond to the GUI parameter name and the value is 357 | the value as specified in the GUI. Drivers 358 | must be able to handle incomplete or invalid dictionaries (i.e. only certain keys are provided) 359 | and complete them to a valid configuration. 360 | """ 361 | 362 | def update_gui_parameters(self, parameters: dict[str, Any]) -> dict[str, Any]: # noqa: ARG002 - defines signature 363 | """Determine the new GUI parameters of the driver depending on the current parameters. 364 | 365 | The available driver's parameters are updated depending on the values that are passed to this function. 366 | The function will then return a dictionary with keys and defaults that correspond to 367 | the current parameter state. Most (simple) drivers will always return the default 368 | GUI parameters. More advanced drivers might return different GUI fields depending on 369 | certain conditions, like other GUI parameters or instrument identification / capabilities. 370 | 371 | Args: 372 | parameters: A dictionary where keys correspond to the GUI parameter name and the value is 373 | the value as specified in the GUI. Drivers 374 | must be able to handle incomplete or invalid dictionaries (i.e. only certain keys are provided) 375 | and complete them to a valid configuration. 376 | When parameters is an empty dictionary, nothing shall be updated 377 | and instead only the defaults shall be returned. 378 | 379 | Returns: 380 | A dictionary where the keys are the fields that shall be shown in the GUI and the values are 381 | the default value. Simple drivers will always return the same defaults. 382 | """ 383 | msg = ("This driver does not implement the update_gui_parameters function. " 384 | "use either set_GUIparameter and get_GUIparameter, " 385 | "or call the update_gui_parameters_with_fallback function.") 386 | raise NotImplementedError(msg) 387 | 388 | def reset_latest_parameters(self) -> None: 389 | """Initialize or reset the saved parameters to their default. 390 | 391 | The parameters will be set to their default values. If Port / Device were already set, these properties 392 | are retained. 393 | 394 | """ 395 | previous_parameters = self._latest_parameters or {} 396 | # we need to do a deepcopy. If the driver has it's defaults in a dictionary, we do not want to change it 397 | if self.uses_update_gui_parameters: 398 | self._latest_parameters = deepcopy(self.update_gui_parameters({})) 399 | else: 400 | self._latest_parameters = deepcopy(self.set_GUIparameter()) 401 | 402 | # if the default for a property is a list (user shall choose one), we use the first element as the default 403 | for key, default in self._latest_parameters.items(): 404 | if isinstance(default, list): 405 | self._latest_parameters[key] = default[0] 406 | 407 | # copy previous Device and Port, if they exist, because they are never part of the defaults 408 | properties_to_copy = ["Device", "Port"] 409 | for property_to_copy in properties_to_copy: 410 | with contextlib.suppress(KeyError): 411 | self._latest_parameters[property_to_copy] = previous_parameters[property_to_copy] 412 | 413 | def set_parameters(self, parameters: dict[str, Any] | None = None) -> None: 414 | """Overwrite GUI parameters. 415 | 416 | Args: 417 | parameters: Dictionary mapping from keys-to-overwrite to the new values. 418 | 419 | """ 420 | if self._latest_parameters is None: 421 | self.reset_latest_parameters() 422 | 423 | if TYPE_CHECKING: 424 | # the reset_latest_parameters() will set self._latest_parameters, so it can't be None any longer 425 | # This assert is used to give that information to type checkers 426 | assert self._latest_parameters is not None 427 | 428 | supported_parameters = [*self._latest_parameters.keys(), "Port", "Label", "Channel", "Device"] 429 | if parameters: 430 | for key, value in parameters.items(): 431 | if key in supported_parameters: 432 | self._latest_parameters[key] = value 433 | else: 434 | msg = ( 435 | f"Keyword '{key}' not supported as parameter. " 436 | f"Supported parameters are: {', '.join(supported_parameters)}" 437 | ) 438 | raise ValueError(msg) 439 | 440 | if self.uses_update_gui_parameters: 441 | if self._latest_parameters: 442 | self.apply_gui_parameters(self._latest_parameters) 443 | self.update_gui_parameters(self._latest_parameters) 444 | else: 445 | self.get_GUIparameter(self._latest_parameters) 446 | 447 | def get_parameters(self) -> dict[str, Any]: 448 | """Retrieve the parameters that are currently saved for the device. 449 | 450 | Returns: 451 | Mapping of parameter keys to their current values. 452 | """ 453 | if self._latest_parameters is None: 454 | self.reset_latest_parameters() 455 | 456 | if TYPE_CHECKING: 457 | # the reset_latest_parameters() will set self._latest_parameters, so it can't be None any longer 458 | # This assert is used to give that information to type checkers 459 | assert self._latest_parameters is not None 460 | 461 | return self._latest_parameters 462 | 463 | def set_port(self, port): 464 | self.port = port 465 | 466 | def get_port(self): 467 | return self.port 468 | 469 | ## can be used by device class to be triggered by button find_Ports 470 | # def find_Ports(self): 471 | 472 | ## not needed anymore here 473 | ## the module checks whether the functions exists 474 | # def get_CalibrationFile_properties(self, port = ""): 475 | 476 | def connect(self): 477 | """Function to be overridden if needed and not using the port manager.""" 478 | 479 | def disconnect(self): 480 | """Function to be overridden if needed.""" 481 | 482 | def initialize(self): 483 | """Function to be overridden if needed.""" 484 | 485 | def deinitialize(self): 486 | """Function to be overridden if needed.""" 487 | 488 | def reconfigure(self, parameters={}, keys=[]): 489 | """Function to be overridden if needed. 490 | 491 | if a GUI parameter changes after replacement with global parameters, the device needs to be reconfigure. 492 | Default behavior is that all parameters are set again and 'configure' is called. 493 | The device class maintainer can redefine/overwrite 'reconfigure' with a more individual procedure. 494 | """ 495 | if self.uses_update_gui_parameters: 496 | if parameters: 497 | self.apply_gui_parameters(parameters) 498 | self.update_gui_parameters(parameters) 499 | else: 500 | self.get_GUIparameter(parameters) 501 | self.configure() 502 | 503 | def configure(self): 504 | """Function to be overridden if needed.""" 505 | 506 | def unconfigure(self): 507 | """Function to be overridden if needed.""" 508 | ## TODO: should be removed in future as these lines are anyway not performed if the function is overridden 509 | if self.idlevalue is not None: 510 | self.value = self.idlevalue 511 | 512 | def poweron(self): 513 | """Function to be overridden if needed.""" 514 | 515 | def poweroff(self): 516 | """Function to be overridden if needed.""" 517 | 518 | def signin(self): 519 | """Function to be overridden if needed.""" 520 | 521 | def signout(self): 522 | """Function to be overridden if needed.""" 523 | 524 | def _transfer(self): 525 | """Function to be overridden if needed.""" 526 | 527 | def start(self): 528 | """Function to be overridden if needed.""" 529 | 530 | def apply(self): 531 | """Function to be overridden if needed.""" 532 | 533 | def reach(self) -> None: 534 | """Actively wait until the applied value is reached. 535 | 536 | Optional, can be overriden by the device class if needed. 537 | """ 538 | 539 | def adapt(self): 540 | """Function to be overridden if needed.""" 541 | 542 | def adapt_ready(self): 543 | """Function to be overridden if needed.""" 544 | 545 | def trigger_ready(self): 546 | """Function to be overridden if needed.""" 547 | 548 | def measure(self): 549 | """Function to be overridden if needed.""" 550 | 551 | def request_result(self): 552 | """Function to be overridden if needed.""" 553 | 554 | def read_result(self): 555 | """Function to be overridden if needed.""" 556 | 557 | def process_data(self): 558 | """Function to be overridden if needed.""" 559 | 560 | def call(self): 561 | """Function to be overridden if needed.""" 562 | return [float("nan") for x in self.variables] 563 | 564 | def finish(self): 565 | """Function to be overridden if needed.""" 566 | 567 | # def set_Parameter(self,feature,value): # not used yet 568 | # pass 569 | 570 | # def get_Parameter(self,feature): # not used yet 571 | # pass 572 | 573 | def stop_Measurement(self, text): 574 | """deprecated: use 'raise Exception(...)' instead 575 | sets flag to stop a measurement, not supported in pysweepme standalone. 576 | """ 577 | self.stopMeasurement = text 578 | return False 579 | 580 | def stop_measurement(self, text): 581 | """deprecated: use 'raise Exception(...)' instead 582 | sets flag to stop a measurement, not supported in pysweepme standalone. 583 | """ 584 | self.stopMeasurement = text 585 | return False 586 | 587 | def write_Log(self, msg): 588 | """deprecated, remains for compatibility reasons.""" 589 | self.message_log(msg) 590 | 591 | def write_log(self, msg): 592 | """deprecated, remains for compatibility reasons.""" 593 | self.message_log(msg) 594 | 595 | def message_log(self, msg): 596 | """Writes message to logbook file.""" 597 | message_log(msg) 598 | 599 | def message_Info(self, msg): 600 | """Command is deprecated, use 'message_info' instead.""" 601 | self.message_info(msg) 602 | 603 | def message_info(self, msg): 604 | """Write to info box.""" 605 | message_info(msg) 606 | 607 | def message_Box(self, msg): 608 | """Command is deprecated, use 'message_box' instead.""" 609 | self.message_box(msg) 610 | 611 | def message_box(self, msg, blocking=False): 612 | """Creates a message box with given message.""" 613 | message_box(msg, blocking) 614 | 615 | def message_balloon(self, msg): 616 | """Creates a message balloon with given message.""" 617 | message_balloon(msg) 618 | 619 | """ convenience functions """ 620 | 621 | def get_variables(self): 622 | """Returns a list of strings being the variable of the Device Class.""" 623 | return self.variables 624 | 625 | def get_units(self): 626 | """Returns a list of strings being the units of the Device Class.""" 627 | return self.units 628 | 629 | def get_variables_units(self): 630 | variable_units = {} 631 | 632 | for var, unit in zip(self.variables, self.units): 633 | variable_units[var] = unit 634 | 635 | return variable_units 636 | 637 | def set_value(self, value): 638 | self.value = value 639 | 640 | def apply_value(self, value): 641 | """Convenience function for user to apply a value, mainly for use with pysweepme.""" 642 | self.value = value 643 | self.apply() 644 | 645 | def write(self, value): 646 | """Applies and reaches the given value as new sweep value for the selected SweepMode.""" 647 | self.start() 648 | self.apply_value(value) 649 | 650 | if hasattr(self, "reach"): 651 | self.reach() 652 | 653 | def read(self): 654 | """\ 655 | returns a list of values according to functions 'get_variables' and 'get_units' 656 | convenience function for pysweepme to quickly retrieve values by calling several semantic standard functions. 657 | """ 658 | self.adapt() 659 | self.adapt_ready() 660 | self.trigger_ready() 661 | self.measure() 662 | self.request_result() 663 | self.read_result() 664 | self.process_data() 665 | return self.call() 666 | -------------------------------------------------------------------------------- /src/pysweepme/Ports.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | # Copyright (c) 2021-2022 SweepMe! GmbH 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 | from __future__ import annotations 23 | 24 | import contextlib 25 | import re 26 | import socket 27 | import time 28 | from typing import Any, Union 29 | 30 | import psutil 31 | 32 | from .ErrorMessage import debug, error 33 | 34 | # import subprocess # needed for TCPIP to find IP addresses 35 | 36 | try: 37 | import serial 38 | import serial.rs485 39 | import serial.tools.list_ports 40 | except: 41 | pass 42 | 43 | try: 44 | import pyvisa 45 | except: 46 | pass 47 | 48 | 49 | def get_debug_info(): 50 | return pyvisa.util.get_debug_info(to_screen=False) 51 | 52 | 53 | def get_porttypes(): 54 | """Returns a list of all supported port types""" 55 | return list(port_types.keys()) 56 | 57 | 58 | def get_resources(keys): 59 | """Returns all resource strings for the given list of port type string""" 60 | resources = [] 61 | 62 | for key in keys: 63 | resources += port_types[key].find_resources() 64 | 65 | return resources 66 | 67 | 68 | def open_resourcemanager(visafile_path=""): 69 | """Returns an open resource manager instance""" 70 | rm = None 71 | 72 | if visafile_path == "": 73 | possible_visa_paths = [ 74 | # default path: 75 | "", 76 | # standard forwarding visa dll: 77 | "C:\\Windows\\System32\\visa32.dll", 78 | # Agilent visa runtime: 79 | "C:\\Program Files (x86)\\IVI Foundation\\VISA\\WinNT\\agvisa\\agbin\\visa32.dll", 80 | # RSvisa runtime: 81 | "C:\\Program Files (x86)\\IVI Foundation\\VISA\\WinNT\\RsVisa\\bin\\visa32.dll", 82 | ] 83 | 84 | for visa_path in possible_visa_paths: 85 | try: 86 | rm = pyvisa.ResourceManager(visa_path) 87 | break 88 | except: 89 | continue 90 | 91 | else: 92 | try: 93 | rm = pyvisa.ResourceManager(visafile_path) 94 | except: 95 | error( 96 | "Creating resource manager from visa dll file '%s' failed." 97 | % visafile_path, 98 | ) 99 | 100 | return rm 101 | 102 | 103 | def close_resourcemanager(): 104 | """Closes the current resource manager instance""" 105 | try: 106 | # print("close resource manager", rm.session) 107 | if rm is not None: 108 | rm.close() 109 | except: 110 | error() 111 | 112 | 113 | def get_resourcemanager(): 114 | """Returns and open resource manager object""" 115 | # first, we have to figure out whether rm is open or closed 116 | # of open session is a handle, otherwise an error is raised 117 | # if rm is closed, we have to renew the resource manager 118 | # to finally return a useful object 119 | 120 | global rm 121 | 122 | try: 123 | rm.session # if object exists the resource manager is open 124 | 125 | except pyvisa.errors.InvalidSession: 126 | rm = open_resourcemanager() 127 | 128 | except AttributeError: # if rm is not defined 129 | return False 130 | 131 | except: 132 | return False 133 | 134 | # print("get resource manager", rm.session) 135 | # print("get visalib", rm.visalib) 136 | 137 | return rm 138 | 139 | 140 | def is_resourcemanager(): 141 | """Check whether there is a resource manager instance""" 142 | if "rm" in globals(): 143 | return True 144 | else: 145 | return False 146 | 147 | 148 | def is_IP(port_str) -> tuple[bool, str, int]: 149 | error_response = (False, "", -1) 150 | port_str = port_str.strip() 151 | result = re.search(r"(\d{1,3}).(\d{1,3}).(\d{1,3}).(\d{1,3}):(\d{1,5})", port_str) 152 | 153 | if not result: 154 | return error_response 155 | 156 | for i in range(1, 5): 157 | if int(result.group(i)) <= 255: 158 | continue 159 | else: 160 | return error_response 161 | 162 | if not 0 < int(result.group(5)) <= 65535: 163 | return error_response 164 | 165 | ip = ".".join([result.group(i) for i in range(1, 5)]) 166 | host = int(result.group(5)) 167 | 168 | return True, ip, host 169 | 170 | 171 | def get_port(ID, properties={}): 172 | """Returns an open port object for the given ID and port properties""" 173 | port: Port 174 | 175 | if ID.startswith("GPIB"): 176 | try: 177 | port = GPIBport(ID) 178 | except: 179 | error("Ports: Cannot create GPIB port object for %s" % ID) 180 | return False 181 | # TODO: Prologix can be removed here, if ID does not start with Prologix anymore 182 | elif ID.startswith("PXI"): 183 | try: 184 | port = PXIport(ID) 185 | except: 186 | error("Ports: Cannot create PXI port object for %s" % ID) 187 | return False 188 | 189 | elif ID.startswith("ASRL"): 190 | try: 191 | port = ASRLport(ID) 192 | except: 193 | error("Ports: Cannot create ASRL port object for %s" % ID) 194 | return False 195 | 196 | elif ID.startswith("TCPIP"): 197 | try: 198 | port = TCPIPport(ID) 199 | except: 200 | error("Ports: Cannot create TCPIP port object for %s" % ID) 201 | return False 202 | 203 | elif ID.startswith("COM"): 204 | try: 205 | port = COMport(ID) 206 | except: 207 | error("Ports: Cannot create COM port object for %s" % ID) 208 | return False 209 | 210 | elif ID.startswith("SOCKET") or is_IP(ID)[0]: 211 | # actually, the ID must not start with SOCKET, it only works for IPv4 addresses 212 | try: 213 | port = SOCKETport(ID) 214 | except Exception: 215 | error("Ports: Cannot create Socket port object for %s" % ID) 216 | 217 | elif ID.startswith("USB") or ID.startswith("USBTMC"): 218 | try: 219 | port = USBTMCport(ID) 220 | except: 221 | error("Ports: Cannot create USBTMC port object for %s" % ID) 222 | return False 223 | 224 | else: 225 | error( 226 | "Ports: Cannot create port object for %s as port type is not defined." % ID, 227 | ) 228 | return False 229 | 230 | # make sure the initial parameters are set 231 | port.initialize_port_properties() 232 | 233 | # here default properties are overwritten by specifications given in the DeviceClass 234 | # only overwrite by the DeviceClass which opens the port to allow to alter the properties further in open() 235 | port.port_properties.update(properties) 236 | 237 | # port is checked if being open and if not, port is opened 238 | if port.port_properties["open"] is False: 239 | # in open(), port_properties can further be changed by global PortDialog settings 240 | port.open() 241 | 242 | if port.port_properties["clear"]: 243 | port.clear() 244 | 245 | return port 246 | 247 | 248 | def close_port(port): 249 | """Close the given port object""" 250 | # port is checked if being open and if so port is closed 251 | if port.port_properties["open"] is True: 252 | port.close() 253 | 254 | 255 | class PortType: 256 | """base class for any port type such as GPIB, COM, USBTMC, etc.""" 257 | 258 | GUIproperties: dict[str, Any] = {} 259 | 260 | properties = { 261 | "VID": None, 262 | "PID": None, 263 | "RegID": None, 264 | "Manufacturer": None, 265 | "Product": None, 266 | "Description": None, 267 | "identification": None, # String returned by the instrument 268 | "query": None, 269 | "Exception": True, # throws exception if no response by port 270 | "EOL": "\n", 271 | "EOLwrite": None, 272 | "EOLread": None, 273 | "timeout": 2, 274 | "delay": 0.0, 275 | "rstrip": True, 276 | "debug": False, 277 | "clear": True, 278 | } 279 | 280 | def __init__(self): 281 | self.ports = {} 282 | 283 | def find_resources(self): 284 | resources = self.find_resources_internal() 285 | return resources 286 | 287 | def find_resources_internal(self): 288 | return [] 289 | 290 | def add_port(self, ID): 291 | pass 292 | 293 | 294 | class COM(PortType): 295 | GUIproperties = { 296 | "baudrate": [ 297 | "50", 298 | "75", 299 | "110", 300 | "134", 301 | "150", 302 | "200", 303 | "300", 304 | "600", 305 | "1200", 306 | "1800", 307 | "2400", 308 | "4800", 309 | "9600", 310 | "19200", 311 | "38400", 312 | "57600", 313 | "115200", 314 | ][::-1], 315 | "terminator": [r"\n", r"\r", r"\r\n", r"\n\r"], 316 | "parity": ["N", "O", "E", "M", "S"], 317 | } 318 | 319 | properties = PortType.properties 320 | 321 | properties.update( 322 | { 323 | "baudrate": 9600, 324 | "bytesize": 8, 325 | "parity": "N", 326 | "stopbits": 1, 327 | "xonxoff": False, 328 | "rtscts": False, 329 | "dsrdtr": False, 330 | "rts": True, 331 | "dtr": True, 332 | "raw_write": False, 333 | "raw_read": False, 334 | "encoding": "latin-1", 335 | }, 336 | ) 337 | 338 | def __init__(self): 339 | super().__init__() 340 | 341 | def find_resources_internal(self): 342 | resources = [] 343 | 344 | # we list all prologix com port addresses to exclude them from the com port resources 345 | prologix_addresses = [] 346 | for controller in get_prologix_controllers(): 347 | prologix_addresses.append(controller.get_address()) 348 | 349 | try: 350 | for ID in serial.tools.list_ports.comports(): 351 | id_str = str(ID.device).split(" ")[0] 352 | 353 | if id_str not in prologix_addresses: 354 | resources.append(id_str) 355 | 356 | except: 357 | error("Error during finding COM ports.") 358 | 359 | return resources 360 | 361 | 362 | class GPIB(PortType): 363 | properties = PortType.properties 364 | 365 | properties.update( 366 | { 367 | "GPIB_EOLwrite": None, 368 | "GPIB_EOLread": None, 369 | }, 370 | ) 371 | 372 | def __init__(self): 373 | super().__init__() 374 | 375 | def find_resources_internal(self): 376 | resources = [] 377 | 378 | # check whether Prologix controller is used 379 | for controller in get_prologix_controllers(): 380 | resources += controller.list_resources() 381 | 382 | # get visa resources 383 | if get_resourcemanager(): 384 | resources += rm.list_resources("GPIB?*") 385 | 386 | # one has to remove Interfaces such as ('GPIB0::INTFC',) 387 | resources = [x for x in resources if "INTFC" not in x] 388 | 389 | return resources 390 | 391 | 392 | class PXI(PortType): 393 | properties = PortType.properties 394 | 395 | properties.update({}) 396 | 397 | def __init__(self): 398 | super().__init__() 399 | 400 | def find_resources_internal(self): 401 | resources = [] 402 | 403 | # get visa resources 404 | if get_resourcemanager(): 405 | resources += rm.list_resources("PXI?*") 406 | 407 | # one has to remove Interfaces such as ('GPIB0::INTFC',) 408 | resources = [x for x in resources if "INTFC" not in x] 409 | 410 | return resources 411 | 412 | 413 | class ASRL(PortType): 414 | properties = PortType.properties 415 | 416 | properties.update( 417 | { 418 | "baudrate": 9600, 419 | "bytesize": 8, 420 | "stopbits": 1, 421 | "parity": "N", 422 | # "flow_control" : 2, 423 | }, 424 | ) 425 | 426 | def __init__(self): 427 | super().__init__() 428 | 429 | def find_resources_internal(self): 430 | resources = [] 431 | 432 | if get_resourcemanager(): 433 | resources += rm.list_resources("ASRL?*") 434 | 435 | return resources 436 | 437 | 438 | class USBdevice: 439 | # created in order to collect all properties in one object 440 | 441 | def __init__(self): 442 | self.properties: dict[str, Any] = {} 443 | 444 | for name in ( 445 | "Availability", 446 | "Caption", 447 | "ClassGuid", 448 | "ConfigManagerUserConfig", 449 | "CreationClassName", 450 | "Description", 451 | "DeviceID", 452 | "ErrorCleared", 453 | "ErrorDescription", 454 | "InstallDate", 455 | "LastErrorCode", 456 | "Manufacturer", 457 | "Name", 458 | "PNPDeviceID", 459 | "PowerManagementCapabilities", 460 | "PowerManagementSupported", 461 | "Service", 462 | "Status", 463 | "StatusInfo", 464 | "SystemCreationClassName", 465 | "SystemName", 466 | ): 467 | self.properties[name] = None 468 | 469 | 470 | class USBTMC(PortType): 471 | properties = PortType.properties 472 | 473 | def __init__(self): 474 | super().__init__() 475 | 476 | def find_resources_internal(self): 477 | resources = [] 478 | 479 | if get_resourcemanager(): 480 | resources += rm.list_resources("USB?*") 481 | 482 | return resources 483 | 484 | 485 | class TCPIP(PortType): 486 | properties = PortType.properties 487 | 488 | properties.update( 489 | { 490 | "TCPIP_EOLwrite": None, 491 | "TCPIP_EOLread": None, 492 | }, 493 | ) 494 | 495 | def __init__(self): 496 | super().__init__() 497 | 498 | def find_resources_internal(self): 499 | resources = [] 500 | 501 | if get_resourcemanager(): 502 | resources += list(rm.list_resources("TCPIP?*")) 503 | 504 | return resources 505 | 506 | 507 | class SOCKET(PortType): 508 | properties = PortType.properties 509 | properties.update( 510 | { 511 | "encoding": "latin-1", 512 | "SOCKET_EOLwrite": None, 513 | "SOCKET_EOLread": None, 514 | }, 515 | ) 516 | 517 | def find_resources_internal(self): 518 | """Find IPv4 addresses""" 519 | connections = psutil.net_connections() 520 | # For UNIX type sockets, conn.laddr will contain a path tuple instead of IP / Port. 521 | # Therefore we must ensure that conn.laddr is actually of the type psutil._common.addr 522 | connection_strings = [ 523 | f"{conn.laddr.ip}:{conn.laddr.port}" 524 | for conn in connections 525 | if conn.status == "LISTEN" 526 | and isinstance(conn.laddr, psutil._common.addr) # noqa: SLF001 527 | and conn.laddr.ip != "0.0.0.0" # noqa: S104 (false positive) 528 | and not conn.laddr.ip.startswith("::") 529 | ] 530 | return connection_strings 531 | 532 | 533 | class Port: 534 | """base class for any port""" 535 | 536 | def __init__(self, ID): 537 | self.port = None 538 | self.port_ID = ID 539 | self.port_properties = { 540 | # The Port Type, e.g. "COM", "GPIB" 541 | "type": type(self).__name__[ 542 | :-4 543 | ], # removing "port" from the end of the port 544 | # Do not use active 545 | "active": True, 546 | # Whether the port is currently opened 547 | "open": False, 548 | # If the port shall be cleared at the beginning of a measurement (after it is opened) 549 | "clear": True, 550 | # Do not use Name 551 | "Name": None, 552 | # Deprecated, use device_communication instead 553 | "NrDevices": 0, 554 | # Enable debugging output for the port 555 | "debug": False, 556 | # String identifying the port to open 'COM3', 'GPIB0::1::INSTR', ... 557 | "ID": self.port_ID, 558 | } 559 | 560 | self.initialize_port_properties() 561 | 562 | self.actualwritetime = time.perf_counter() 563 | 564 | def __del__(self): 565 | pass 566 | 567 | def initialize_port_properties(self): 568 | # we need to know the PortType Object 569 | self.port_type = type(port_types[self.port_properties["type"]]) 570 | 571 | # we have to overwrite with the properties of the Port_type 572 | self.update_properties(self.port_type.properties) 573 | 574 | # in case any port like to do something special, it has the chance now 575 | self.initialize_port_properties_internal() 576 | 577 | def initialize_port_properties_internal(self): 578 | pass 579 | 580 | def update_properties(self, properties={}): 581 | self.port_properties.update(properties) 582 | 583 | def set_logging(self, state): 584 | self.port_properties["debug"] = bool(state) 585 | 586 | def get_logging(self): 587 | return self.port_properties["debug"] 588 | 589 | def get_identification(self) -> str: 590 | return "not available" 591 | 592 | def open(self): 593 | self.open_internal() 594 | 595 | self.port_properties["open"] = True 596 | 597 | def open_internal(self): 598 | pass 599 | 600 | def close(self): 601 | self.close_internal() 602 | 603 | self.port_properties["open"] = False 604 | 605 | def close_internal(self): 606 | pass 607 | 608 | def clear(self) -> None: 609 | """Clears the port, can have different meaning depending on each port.""" 610 | self.clear_internal() 611 | 612 | def clear_internal(self) -> None: 613 | """Function to be overwritten by each port to device what is done during clear.""" 614 | 615 | def write(self, cmd: str) -> None: 616 | """Write a command via a port.""" 617 | if self.port_properties["debug"]: 618 | debug(" ".join([self.port_properties["ID"], "write:", repr(cmd)])) 619 | 620 | if cmd != "": 621 | self.write_internal(cmd) 622 | 623 | def write_internal(self, cmd: str) -> None: 624 | """Function to be overwritten by each port to define how to write a command.""" 625 | 626 | def write_raw(self, cmd) -> None: 627 | """Write a command via a port without encoding.""" 628 | if cmd != "": 629 | self.write_raw_internal(cmd) 630 | 631 | def write_raw_internal(self, cmd) -> None: 632 | """Function to be overwritten by each port to define how to write a command without encoding.""" 633 | # if this function is not overwritten, it defines a fallback to write() 634 | self.write(cmd) 635 | 636 | def read(self, digits=0) -> str: 637 | """Read a command from a port.""" 638 | answer = self.read_internal(digits) 639 | 640 | # with 'raw_read', everything should be returned. 641 | if self.port_properties["rstrip"] and not self.port_properties["raw_read"]: 642 | answer = answer.rstrip() 643 | 644 | if self.port_properties["debug"]: 645 | debug(" ".join([self.port_properties["ID"], "read:", repr(answer)])) 646 | 647 | # each port must decide on its own whether an empty string is a timeout error or not 648 | # if answer == "" and self.port_properties["Exception"] == True: 649 | # raise Exception('Port \'%s\' with ID \'%s\' does not respond. Check port properties, e.g. ' 650 | # 'timeout, EOL,..' % (self.port_properties["type"],self.port_properties["ID"]) ) 651 | 652 | return answer 653 | 654 | def read_internal(self, digits: int) -> str: 655 | """Function to be overwritten by each port to define how to read a command.""" 656 | return "" 657 | 658 | def read_raw(self, digits: int = 0) -> bytes: 659 | """Read a command without decoding.""" 660 | return self.read_raw_internal(digits) 661 | 662 | def read_raw_internal(self, digits: int) -> bytes: # noqa: ARG002 663 | """Function to be overwritten by each port to define how to read a command without decoding.""" 664 | msg = (f"Reading raw data from port type {self.port_properties['type']} is not implemented yet. " 665 | f"Consider using port.port.read_raw() instead.") 666 | raise NotImplementedError(msg) 667 | 668 | def query(self, cmd: str, digits: int = 0) -> str: 669 | """Write a command to the port and read the response.""" 670 | self.write(cmd) 671 | return self.read(digits=digits) 672 | 673 | 674 | class GPIBport(Port): 675 | port: Union[pyvisa.resources.GPIBInstrument, PrologixGPIBcontroller] 676 | 677 | def __init__(self, ID): 678 | super().__init__(ID) 679 | 680 | def open_internal(self): 681 | # differentiate between visa GPIB and prologix_controller 682 | if "Prologix" in self.port_properties["ID"]: 683 | # we take the last part of the ID and cutoff 'Prologix@' to get the COM port 684 | com_port = self.port_properties["ID"].split("::")[-1][9:] 685 | 686 | # the prologix controller behaves like a port object 687 | # and has all function like open, close, clear, write, read 688 | self.port = prologix_controller[com_port] 689 | 690 | # we give the prologix GPIB port the chance to setup 691 | self.port.open(self.port_properties) 692 | 693 | else: 694 | if get_resourcemanager() is False: 695 | return 696 | self.port: pyvisa.resources.Resource 697 | 698 | self.port = rm.open_resource(self.port_properties["ID"]) 699 | if isinstance(self.port, PrologixGPIBcontroller): 700 | raise TypeError( 701 | "Prologix port resource found within non-prologix port object.", 702 | ) 703 | 704 | self.port.timeout = ( 705 | self.port_properties["timeout"] * 1000 706 | ) # must be in ms now 707 | 708 | if self.port_properties["GPIB_EOLwrite"] is not None: 709 | self.port.write_termination = self.port_properties["GPIB_EOLwrite"] 710 | 711 | if self.port_properties["GPIB_EOLread"] is not None: 712 | self.port.read_termination = self.port_properties["GPIB_EOLread"] 713 | 714 | def close_internal(self): 715 | self.port.close() 716 | 717 | def clear_internal(self) -> None: 718 | """Clear the port.""" 719 | self.port.clear() 720 | 721 | def get_identification(self): 722 | self.write("*IDN?") 723 | return self.read() 724 | 725 | def write_internal(self, cmd): 726 | while ( 727 | time.perf_counter() - self.actualwritetime < self.port_properties["delay"] 728 | ): 729 | time.sleep(0.01) 730 | 731 | if "Prologix" in self.port_properties["ID"]: 732 | self.port.write(cmd, self.port_properties["ID"].split("::")[1]) 733 | 734 | else: 735 | self.port.write(cmd) 736 | 737 | self.actualwritetime = time.perf_counter() 738 | 739 | def read_internal(self, digits=0): 740 | if "Prologix" in self.port_properties["ID"]: 741 | answer = self.port.read(self.port_properties["ID"].split("::")[1]) 742 | else: 743 | if isinstance(self.port, PrologixGPIBcontroller): 744 | raise TypeError( 745 | "Prologix port resource found within non-prologix port object.", 746 | ) 747 | answer = self.port.read() 748 | 749 | return answer 750 | 751 | 752 | class PXIport(Port): 753 | port: pyvisa.resources.PXIInstrument 754 | 755 | def __init__(self, ID): 756 | super().__init__(ID) 757 | 758 | def open_internal(self): 759 | if get_resourcemanager() is False: 760 | return 761 | 762 | self.port = rm.open_resource(self.port_properties["ID"]) 763 | self.port.timeout = self.port_properties["timeout"] * 1000 # must be in ms now 764 | 765 | def close_internal(self): 766 | self.port.close() 767 | 768 | def clear_internal(self) -> None: 769 | """Clear the port.""" 770 | self.port.clear() 771 | 772 | def get_identification(self): 773 | self.write("*IDN?") 774 | return self.read() 775 | 776 | def write_internal(self, cmd): 777 | while ( 778 | time.perf_counter() - self.actualwritetime < self.port_properties["delay"] 779 | ): 780 | time.sleep(0.01) 781 | 782 | self.actualwritetime = time.perf_counter() 783 | exc_msg = ( 784 | "Writing to PXIInstruments has not been implemented yet " 785 | "and needs to be handled by the driver itself." 786 | ) 787 | raise NotImplementedError(exc_msg) 788 | 789 | def read_internal(self, digits=0): 790 | exc_msg = ( 791 | "Reading from PXIInstruments has not been implemented yet " 792 | "and needs to be handled by the driver itself." 793 | ) 794 | raise NotImplementedError(exc_msg) 795 | 796 | 797 | class ASRLport(Port): 798 | port: pyvisa.resources.SerialInstrument 799 | 800 | def __init__(self, ID): 801 | super().__init__(ID) 802 | 803 | from pyvisa.constants import Parity, StopBits 804 | 805 | self.parities = { 806 | "N": Parity.none, 807 | "O": Parity.odd, 808 | "E": Parity.even, 809 | "M": Parity.mark, 810 | "S": Parity.space, 811 | } 812 | 813 | self.stopbits = { 814 | 1: StopBits.one, 815 | 1.5: StopBits.one_and_a_half, 816 | 2: StopBits.two, 817 | } 818 | 819 | # def initialize_port_properties_internal(self): 820 | 821 | # self.port_properties.update({ 822 | # "baudrate" : 9600, 823 | # "bytesize" : 8, 824 | # "stopbits" : 1, 825 | # "parity" : "N", 826 | # "flow_control" : 2, 827 | # }) 828 | 829 | def open_internal(self): 830 | if get_resourcemanager() is False: 831 | return 832 | 833 | self.port = rm.open_resource(self.port_properties["ID"]) 834 | self.port.timeout = ( 835 | int(self.port_properties["timeout"]) * 1000 836 | ) # must be in ms now 837 | self.port.baud_rate = int(self.port_properties["baudrate"]) 838 | self.port.data_bits = int(self.port_properties["bytesize"]) 839 | self.port.stop_bits = self.stopbits[float(self.port_properties["stopbits"])] 840 | self.port.parity = self.parities[str(self.port_properties["parity"])] 841 | # self.port.flow_control = self.parities[str(self.port_properties["parity"])] 842 | 843 | def close_internal(self): 844 | self.port.close() 845 | self.port_properties["open"] = False 846 | 847 | def clear_internal(self) -> None: 848 | """Clear the port.""" 849 | self.port.clear() 850 | 851 | def write_internal(self, cmd): 852 | self.port.write(cmd) 853 | time.sleep(self.port_properties["delay"]) 854 | 855 | def read_internal(self, digits=0): 856 | answer = self.port.read() 857 | 858 | return answer 859 | 860 | 861 | class USBTMCport(Port): 862 | port: pyvisa.resources.USBInstrument 863 | 864 | def __init__(self, ID): 865 | super().__init__(ID) 866 | 867 | def open_internal(self): 868 | if get_resourcemanager() is False: 869 | return 870 | 871 | self.port = rm.open_resource(self.port_properties["ID"]) 872 | self.port.timeout = self.port_properties["timeout"] * 1000 # must be in ms now 873 | 874 | def close_internal(self): 875 | self.port.close() 876 | 877 | def clear_internal(self) -> None: 878 | """Clear the port.""" 879 | self.port.clear() 880 | 881 | def get_identification(self): 882 | self.write("*IDN?") 883 | return self.read() 884 | 885 | def write_internal(self, cmd): 886 | self.port.write(cmd) 887 | 888 | def read_internal(self, digits=0): 889 | answer = self.port.read() 890 | return answer 891 | 892 | def read_raw_internal(self, digits: int) -> bytes: 893 | """Read raw data without decoding.""" 894 | digits_or_none = None if digits <= 0 else digits 895 | return self.port.read_raw(digits_or_none) 896 | 897 | 898 | class TCPIPport(Port): 899 | port: pyvisa.resources.TCPIPInstrument 900 | 901 | def __init__(self, ID): 902 | super().__init__(ID) 903 | 904 | def open_internal(self): 905 | if get_resourcemanager() is False: 906 | return 907 | 908 | self.port = rm.open_resource(self.port_properties["ID"]) 909 | self.port.timeout = self.port_properties["timeout"] * 1000 # must be in ms now 910 | 911 | if self.port_properties["TCPIP_EOLwrite"] is not None: 912 | self.port.write_termination = self.port_properties["TCPIP_EOLwrite"] 913 | 914 | if self.port_properties["TCPIP_EOLread"] is not None: 915 | self.port.read_termination = self.port_properties["TCPIP_EOLread"] 916 | 917 | def close_internal(self): 918 | self.port.close() 919 | 920 | def clear_internal(self) -> None: 921 | """Clear the port.""" 922 | self.port.clear() 923 | 924 | def get_identification(self): 925 | self.write("*IDN?") 926 | return self.read() 927 | 928 | def write_internal(self, cmd): 929 | self.port.write(cmd) 930 | time.sleep(self.port_properties["delay"]) 931 | 932 | def read_internal(self, digits=0): 933 | answer = self.port.read() 934 | 935 | return answer 936 | 937 | 938 | class SOCKETport(Port): 939 | """Port class for sockets.""" 940 | 941 | port: socket.socket 942 | 943 | def __init__(self, ID: str) -> None: 944 | """Initialize the socket port.""" 945 | super().__init__(ID) 946 | 947 | self.buffer: bytes = b"" 948 | """A buffer to store the incoming data.""" 949 | 950 | self.write_termination: str = "" 951 | self.read_termination: str = "" 952 | self.last_write_time: float = 0.0 953 | """The last time a write command was executed.""" 954 | 955 | def open_internal(self) -> None: 956 | """Open the socket.""" 957 | self.clear_buffer() 958 | 959 | port_id = self.port_properties["ID"] 960 | ok, HOST, PORT = is_IP(port_id) 961 | if not ok: 962 | # this can happen if HOST is no IPv4 address but a domain or localhost 963 | HOST, PORT = port_id.split(":") 964 | 965 | self.port = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 966 | self.port.settimeout(0.1) 967 | self.port.connect((HOST, int(PORT))) 968 | 969 | if self.port_properties["SOCKET_EOLwrite"] is not None: 970 | self.write_termination = self.port_properties["SOCKET_EOLwrite"] 971 | else: 972 | self.write_termination = "" 973 | 974 | if self.port_properties["SOCKET_EOLread"] is not None: 975 | self.read_termination = self.port_properties["SOCKET_EOLread"] 976 | else: 977 | self.read_termination = "" 978 | 979 | self.last_write_time = time.time() 980 | 981 | def close_internal(self) -> None: 982 | """Closing the socket.""" 983 | self.port.close() 984 | 985 | def get_identification(self) -> str: 986 | """Returns the *IDN of the socket.""" 987 | self.write("*IDN?") 988 | return self.read() 989 | 990 | def clear_internal(self) -> None: 991 | """Read out the buffer until its empty.""" 992 | # Set a 1ms timeout for maximum read out speed 993 | # Use >0 to avoid non-blocking mode (which might not read out all data) 994 | timeout = self.port.gettimeout() 995 | try: 996 | self.port.settimeout(0.001) 997 | with contextlib.suppress(socket.timeout): 998 | while True: 999 | data = self.read_chunk(4096) 1000 | if not data: 1001 | break 1002 | finally: 1003 | # Set the timeout back to the original value 1004 | self.port.settimeout(timeout) 1005 | self.clear_buffer() 1006 | 1007 | def write_internal(self, cmd: str) -> None: 1008 | """Write the command to the port and wait for the delay time.""" 1009 | if time.time() - self.last_write_time < self.port_properties["delay"]: 1010 | time.sleep( 1011 | self.port_properties["delay"] - (time.time() - self.last_write_time), 1012 | ) 1013 | 1014 | encoding = self.port_properties["encoding"] 1015 | self.port.sendall((cmd + self.write_termination).encode(encoding)) 1016 | 1017 | self.last_write_time = time.time() 1018 | 1019 | def read_internal(self, digits: int = 0) -> str: 1020 | """Read until EOL character is found or a given number of digits is reached. 1021 | 1022 | Returns the minimum of the two. 1023 | Digits can only be used for single byte encodings. 1024 | """ 1025 | start_t = time.time() 1026 | eol = self.read_termination 1027 | encoding = self.port_properties["encoding"] 1028 | 1029 | while True: 1030 | # If both EOL and digits are given, return the minimum of both 1031 | # Start with a digit_index larger than the available buffer size to trigger the readout if it is not updated 1032 | digit_index = len(self.buffer) + 1 1033 | if 0 < digits <= len(self.buffer): 1034 | digit_index = digits 1035 | 1036 | # Start with an EOL index larger than the available buffer size to trigger the readout if it is not updated 1037 | eol_index = float("inf") 1038 | if eol and eol.encode(encoding) in self.buffer: 1039 | eol_bytes = eol.encode(encoding) 1040 | eol_index = self.buffer.find(eol_bytes) + len(eol_bytes) 1041 | 1042 | answer_index = min(digit_index, eol_index) 1043 | if 0 < answer_index <= len(self.buffer): 1044 | answer = self.buffer[: int(answer_index)] 1045 | self.buffer = self.buffer[int(answer_index) :] 1046 | return answer.decode(encoding) 1047 | 1048 | if time.time() - start_t > float(self.port_properties["timeout"]): 1049 | msg = "No EOL found or sufficient digits received from socket." 1050 | raise TimeoutError(msg) 1051 | 1052 | # Otherwise, read more data 1053 | missing_bytes = digits - len(self.buffer) if digits > 0 else 1024 1054 | with contextlib.suppress(socket.timeout): 1055 | self.buffer += self.read_chunk(missing_bytes) 1056 | 1057 | def read_chunk(self, digits: int = 1024) -> bytes: 1058 | """Read a chunk of data from the socket.""" 1059 | return self.port.recv(digits) 1060 | 1061 | def clear_buffer(self) -> None: 1062 | """Clear the buffer.""" 1063 | self.buffer = b"" 1064 | 1065 | 1066 | class COMport(Port): 1067 | port: serial.Serial 1068 | 1069 | def __init__(self, ID): 1070 | super().__init__(ID) 1071 | 1072 | self.port = serial.Serial() 1073 | 1074 | # def initialize_port_properties_internal(self): 1075 | 1076 | # self.port_properties.update({ 1077 | # "baudrate": 9600, 1078 | # "bytesize": 8, 1079 | # "parity": 'N', 1080 | # "stopbits": 1, 1081 | # "xonxoff": False, 1082 | # "rtscts": False, 1083 | # "dsrdtr": False, 1084 | # "rts": True, 1085 | # "dtr": True, 1086 | # "raw_write": False, 1087 | # "raw_read": False, 1088 | # "encoding": "latin-1", 1089 | # }) 1090 | 1091 | def refresh_port(self): 1092 | self.port.port = str(self.port_properties["ID"]) 1093 | self.port.timeout = float(self.port_properties["timeout"]) 1094 | self.port.baudrate = int(self.port_properties["baudrate"]) 1095 | self.port.bytesize = int(self.port_properties["bytesize"]) 1096 | self.port.parity = str(self.port_properties["parity"]) 1097 | self.port.stopbits = self.port_properties["stopbits"] 1098 | self.port.xonxoff = bool(self.port_properties["xonxoff"]) 1099 | self.port.rtscts = bool(self.port_properties["rtscts"]) 1100 | self.port.dsrdtr = bool(self.port_properties["dsrdtr"]) 1101 | self.port.rts = bool(self.port_properties["rts"]) 1102 | self.port.dtr = bool(self.port_properties["dtr"]) 1103 | 1104 | def open_internal(self): 1105 | self.refresh_port() 1106 | 1107 | if not self.port.is_open: 1108 | self.port.open() 1109 | else: 1110 | self.port.close() 1111 | self.port.open() 1112 | 1113 | def close_internal(self): 1114 | self.port.close() 1115 | self.port_properties["open"] = False 1116 | 1117 | def clear_internal(self) -> None: 1118 | """Clear the port.""" 1119 | self.port.reset_input_buffer() 1120 | self.port.reset_output_buffer() 1121 | 1122 | def write_internal(self, cmd): 1123 | while ( 1124 | time.perf_counter() - self.actualwritetime < self.port_properties["delay"] 1125 | ): 1126 | time.sleep(0.01) 1127 | 1128 | if self.port_properties["EOLwrite"] is not None: 1129 | eol = self.port_properties["EOLwrite"] 1130 | else: 1131 | eol = self.port_properties["EOL"] 1132 | 1133 | if not self.port_properties["raw_write"]: 1134 | try: 1135 | cmd_bytes = (cmd + eol).encode(self.port_properties["encoding"]) 1136 | except: 1137 | cmd_bytes = cmd + eol.encode(self.port_properties["encoding"]) 1138 | 1139 | else: 1140 | cmd_bytes = cmd + eol.encode(self.port_properties["encoding"]) 1141 | # just send cmd as is without any eol/terminator because of raw_write 1142 | 1143 | self.port.write(cmd_bytes) 1144 | 1145 | self.actualwritetime = time.perf_counter() 1146 | 1147 | def read_internal(self, digits=0): 1148 | if digits == 0: 1149 | answer, EOLfound = self.readline() 1150 | 1151 | if not self.port_properties["raw_read"]: 1152 | try: 1153 | answer = answer.decode(self.port_properties["encoding"]) 1154 | except: 1155 | error( 1156 | "Unable to decode the reading from %s. Please check whether the baudrate " 1157 | "and the terminator are correct (Ports -> PortManager -> COM). " 1158 | "You can get the raw reading by setting the key 'raw_read' of " 1159 | "self.port_properties to True" % (self.port_properties["ID"]), 1160 | ) 1161 | raise 1162 | 1163 | else: 1164 | answer = self.port.read(digits) 1165 | 1166 | EOLfound = True 1167 | 1168 | if not self.port_properties["raw_read"]: 1169 | try: 1170 | answer = answer.decode(self.port_properties["encoding"]) 1171 | except: 1172 | error( 1173 | "Unable to decode the reading from %s. Please check whether the baudrate " 1174 | "and the terminator are correct (Ports -> PortManager -> COM). " 1175 | "You can get the raw reading by setting the key 'raw_read' of " 1176 | "self.port_properties to True" % (self.port_properties["ID"]), 1177 | ) 1178 | raise 1179 | 1180 | if answer == "" and not EOLfound and self.port_properties["Exception"] is True: 1181 | raise Exception( 1182 | "Port '%s' with ID '%s' does not respond.\n" 1183 | "Check port properties, e.g. timeout, EOL,.. via Port -> PortManager -> COM" 1184 | % (self.port_properties["type"], self.port_properties["ID"]), 1185 | ) 1186 | 1187 | return answer 1188 | 1189 | def write_raw_internal(self, cmd): 1190 | current = self.port_properties["raw_write"] 1191 | self.port_properties["raw_write"] = True 1192 | self.write(cmd) 1193 | self.port_properties["raw_write"] = current 1194 | 1195 | def read_raw_internal(self, digits): 1196 | current = self.port_properties["raw_read"] 1197 | self.port_properties["raw_read"] = True 1198 | answer = self.read(digits) 1199 | self.port_properties["raw_read"] = current 1200 | 1201 | return answer 1202 | 1203 | def in_waiting(self): 1204 | return self.port.in_waiting 1205 | 1206 | def readline(self): 1207 | # this function allows to change the EOL, rewritten from pyserial 1208 | 1209 | if self.port_properties["EOLread"] is not None: 1210 | EOL = self.port_properties["EOLread"].encode( 1211 | self.port_properties["encoding"], 1212 | ) 1213 | else: 1214 | EOL = self.port_properties["EOL"].encode(self.port_properties["encoding"]) 1215 | 1216 | leneol = len(EOL) 1217 | line = bytearray() 1218 | 1219 | eol_found = False 1220 | 1221 | while True: 1222 | c = self.port.read(1) 1223 | if c: 1224 | line += c 1225 | if line[-leneol:] == EOL: 1226 | eol_found = True 1227 | break 1228 | 1229 | else: 1230 | break 1231 | 1232 | return bytes(line[:-leneol]), eol_found 1233 | 1234 | def get_identification(self) -> str: 1235 | """Get details of the COM port. 1236 | 1237 | In contrast to the other get_identification functions which return instrument identifications, this 1238 | function returns details about the COM Port (or USB-COM adapter if one is used) itself. 1239 | Therefore this should be considered an unstable feature that might be changed in the future. 1240 | 1241 | Returns: 1242 | The hwid of the COM port. 1243 | """ 1244 | ports = serial.tools.list_ports.comports() 1245 | 1246 | port_info = "No info available" 1247 | for port in ports: 1248 | if port.device == self.port_ID: 1249 | debug( 1250 | "Identification for COM ports is an experimental feature and will probably change in the future.", 1251 | ) 1252 | port_info = port.hwid 1253 | break 1254 | 1255 | return port_info 1256 | 1257 | 1258 | class PrologixGPIBcontroller: 1259 | def __init__(self, address): 1260 | # basically the address could be used for COM ports but also for Ethernet 1261 | # at the moment, only COM is supported, but Ethernet could be added later 1262 | self.set_address(address) 1263 | 1264 | self._current_gpib_ID = None 1265 | 1266 | self.ID_port_properties = {} 1267 | 1268 | self.port = serial.Serial() 1269 | self.port.port = self.get_address() 1270 | self.port.baudrate = 115200 # fixed, Prologix adapter automatically recognize the baudrate (tested with v6.0) 1271 | 1272 | self.terminator_character = { 1273 | "\r\n": 0, 1274 | "\r": 1, 1275 | "\n": 2, 1276 | "": 3, 1277 | } 1278 | 1279 | def set_address(self, address): 1280 | self._address = str(address) 1281 | 1282 | def get_address(self): 1283 | return self._address 1284 | 1285 | def list_resources(self): 1286 | if self._address is not None: 1287 | return [ 1288 | "GPIB::%i::Prologix@%s" % (i, self._address) for i in range(1, 31, 1) 1289 | ] 1290 | else: 1291 | return [] 1292 | 1293 | def open(self, port_properties): 1294 | ID = port_properties["ID"].split("::")[1] 1295 | 1296 | self.ID_port_properties[ID] = port_properties 1297 | 1298 | if not self.port.is_open: 1299 | self.port.open() 1300 | 1301 | self.port.timeout = self.ID_port_properties[ID]["timeout"] 1302 | self.port.timeout = 0.1 1303 | 1304 | self.port.reset_input_buffer() 1305 | self.port.reset_output_buffer() 1306 | 1307 | self.set_controller_in_charge() # Controller in Charge CIC 1308 | 1309 | self.set_mode(1) # 1 = controller mode 1310 | 1311 | terminator = "\r\n" 1312 | 1313 | if self.ID_port_properties[ID]["GPIB_EOLwrite"] is not None: 1314 | terminator = self.ID_port_properties[ID]["GPIB_EOLwrite"] 1315 | 1316 | if self.ID_port_properties[ID]["GPIB_EOLread"] is not None: 1317 | terminator = self.ID_port_properties[ID]["GPIB_EOLread"] 1318 | 1319 | if terminator in self.terminator_character: 1320 | terminator_index = self.terminator_character[terminator] 1321 | else: 1322 | debug( 1323 | "Terminator '%s' cannot be set for Prologix adapter at %s. " 1324 | "Fallback to CR/LF." % (repr(terminator), str(ID)), 1325 | ) 1326 | terminator_index = 0 # CR/LF 1327 | 1328 | self.set_eos(terminator_index) # see self.terminator_character for all options 1329 | 1330 | self.set_eoi(1) # 1 = eoi at end 1331 | 1332 | self.set_auto(0) # 0 = no read-after-write 1333 | 1334 | self.set_read_timeout(0.05) # read timeout in s 1335 | # self.set_readtimeout(self.ID_port_properties[ID]["timeout"]) # read timeout in s 1336 | 1337 | # print("mode to listenonly set") 1338 | 1339 | def clear(self): 1340 | if self.port.is_open: 1341 | self.port.reset_input_buffer() 1342 | self.port.reset_output_buffer() 1343 | 1344 | def close(self): 1345 | if self.port.is_open: 1346 | self.port.close() 1347 | 1348 | def write(self, cmd="", ID=""): 1349 | """Sends a non-empty command string to the prologix controller 1350 | and changes the GPIB address if needed beforehand 1351 | """ 1352 | if cmd != "": 1353 | if ID == "" or cmd.startswith("++"): 1354 | msg = (cmd + "\n").encode("latin-1") 1355 | 1356 | else: 1357 | if self._current_gpib_ID != ID: 1358 | self._current_gpib_ID = str(ID) 1359 | 1360 | # set to current GPIB address 1361 | # calls 'write' again, but as the command starts with '++' will not lead to an endless iteration 1362 | self.write("++addr %s" % self._current_gpib_ID) 1363 | 1364 | # some special characters need to be escaped before sending 1365 | # we start to replace ESC as it will be added by other commands as well 1366 | # and would be otherwise replaced again 1367 | cmd.replace(chr(27), chr(27) + chr(27)) # ESC (ASCII 27) 1368 | cmd.replace(chr(13), chr(27) + chr(13)) # CR (ASCII 13) 1369 | cmd.replace(chr(10), chr(27) + chr(10)) # LF (ASCII 10) 1370 | cmd.replace(chr(43), chr(27) + chr(43)) # ‘+’ (ASCII 43) 1371 | 1372 | msg = (cmd + "\n").encode(self.ID_port_properties[ID]["encoding"]) 1373 | 1374 | # print("write:", msg) 1375 | self.port.write(msg) 1376 | 1377 | def read(self, ID): 1378 | """Requests an answer from the instruments and returns it""" 1379 | # needed to make sure that there is a short delay since the last write 1380 | # time.sleep(self.ID_port_properties[ID]["delay"]) 1381 | 1382 | # print("in waiting:", self.port.in_waiting) 1383 | 1384 | starttime = time.perf_counter() 1385 | 1386 | msg = b"" 1387 | 1388 | while time.perf_counter() - starttime < self.ID_port_properties[ID]["timeout"]: 1389 | self.write("++read eoi") # requesting an answer 1390 | 1391 | msg += self.port.readline() 1392 | # print("Prologix read message:", msg) 1393 | 1394 | if b"\n" in msg: 1395 | break 1396 | 1397 | if self.ID_port_properties[ID]["rstrip"]: 1398 | msg = msg.rstrip() 1399 | 1400 | return msg.decode(self.ID_port_properties[ID]["encoding"]) 1401 | 1402 | def set_controller_in_charge(self): 1403 | self.write("++ifc") 1404 | 1405 | def set_mode(self, mode): 1406 | self.write("++mode %s" % str(mode)) 1407 | 1408 | def get_mode(self): 1409 | self.write("++mode") 1410 | return self.port.readline().rstrip().decode() 1411 | 1412 | def set_eos(self, eos): 1413 | self.write( 1414 | "++eos %s" % str(eos), 1415 | ) # EOS terminator - 0:CR+LF, 1:CR, 2:LF, 3:None 1416 | 1417 | def get_eos(self): 1418 | self.write("++eos") 1419 | return self.port.readline().rstrip().decode() 1420 | 1421 | def set_eoi(self, eoi): 1422 | self.write("++eoi %s" % str(eoi)) # 0 = no eoi at end, 1 = eoi at end 1423 | 1424 | def get_eoi(self): 1425 | self.write("++eoi") 1426 | return self.port.readline().rstrip().decode() 1427 | 1428 | def set_auto(self, auto): 1429 | self.write( 1430 | "++auto %s" % str(auto), 1431 | ) # 0 not read-after-write, 1 = read-after-write 1432 | 1433 | def get_auto(self): 1434 | self.write("++auto") 1435 | return self.port.readline().rstrip().decode() 1436 | 1437 | def set_read_timeout(self, readtimeout): 1438 | """Set the read timeout in s""" 1439 | # conversion from s to ms, maximum is 3000, minimum is 1 1440 | self.write( 1441 | "++read_tmo_ms %i" % int(max(1, min(3000, float(readtimeout) * 1000))), 1442 | ) 1443 | 1444 | def get_readtimeout(self): 1445 | self.write("++read_tmo_ms") 1446 | return ( 1447 | float(self.port.readline().rstrip().decode()) / 1000.0 1448 | ) # conversion from ms to s 1449 | 1450 | def set_listenonly(self, listenonly): 1451 | """Set listen-only, only supported in mode = device!""" 1452 | self.write( 1453 | "++lon %s" % str(listenonly), 1454 | ) # 0 disable 'listen-only' mode, 1 enable 'listen-only' mode 1455 | 1456 | def get_listenonly(self): 1457 | self.write("++lon") 1458 | return self.port.readline().rstrip().decode() 1459 | 1460 | def get_version(self): 1461 | self.write("++ver") 1462 | return self.port.readline().rstrip().decode() 1463 | 1464 | 1465 | def add_prologix_controller(address): 1466 | controller = PrologixGPIBcontroller(address) 1467 | prologix_controller[address] = controller 1468 | 1469 | 1470 | def remove_prologix_controller(address): 1471 | if address in prologix_controller: 1472 | del prologix_controller[address] 1473 | 1474 | 1475 | def get_prologix_controllers(): 1476 | return list(prologix_controller.values()) 1477 | 1478 | 1479 | prologix_controller: dict[str, PrologixGPIBcontroller] = {} 1480 | # add_prologix_controller("COM23") 1481 | 1482 | rm = open_resourcemanager() 1483 | 1484 | port_types = { 1485 | "COM": COM(), 1486 | # "MODBUS": MODBUS(), 1487 | "GPIB": GPIB(), 1488 | "PXI": PXI(), 1489 | # "ASRL": ASRL(), # Serial communication via visa runtime, just used for testing at the moment 1490 | "USBTMC": USBTMC(), 1491 | "TCPIP": TCPIP(), 1492 | "SOCKET": SOCKET(), 1493 | # "VB": VirtualBench(), # no longer supported as finding ports can be done in Device Class / Driver 1494 | } 1495 | --------------------------------------------------------------------------------