From b1d14e377b682ecc7e8c06edadcf041ff6b0049f Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Fri, 10 Jun 2022 14:24:43 -0700 Subject: [PATCH 1/6] add TCPIP instrument discovery --- CHANGES | 4 ++ docs/source/installation.rst | 6 ++- pyproject.toml | 2 + pyvisa_py/protocols/rpc.py | 52 +++++++++++++++++++ pyvisa_py/tcpip.py | 51 +++++++++++++++++- .../test_resource_manager.py | 19 +++++-- 6 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index e1f8cf25..e33b714b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,10 @@ PyVISA-py Changelog =================== +0.5.4 (Unreleased) +------------------ +- Implement list_resources for TCPIP instruments + 0.5.3 (12-05-2022) ------------------ - fix tcp/ip connections dropping from inside Docker containers after 5 minute idling #285 diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 9310bc52..bdf5ec6a 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -23,6 +23,9 @@ Pyvisa-py relies on :py:mod:`socket` module in the Python Standard Library to interact with the instrument which you do not need to install any extra library to access those resources. +To discover devices on all interfaces, please install `psutil`_. Otherwise, +discovery will only occur on the default network interface. + Serial resources: ASRL INSTR ---------------------------- @@ -97,4 +100,5 @@ form GitHub_:: .. _`LibreVISA`: http://www.librevisa.org/ .. _`issue tracker`: https://github.com/pyvisa/pyvisa-py/issues .. _`linux-gpib`: http://linux-gpib.sourceforge.net/ -.. _`gpib-ctypes`: https://pypi.org/project/gpib-ctypes/ \ No newline at end of file +.. _`gpib-ctypes`: https://pypi.org/project/gpib-ctypes/ +.. _`psutil`: https://pypi.org/project/psutil/ diff --git a/pyproject.toml b/pyproject.toml index a822531d..68d2846e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dynamic=["version"] gpib-ctypes = ["gpib-ctypes>=0.3.0"] serial = ["pyserial>=3.0"] usb = ["pyusb"] +psutil = ["psutil"] + [project.urls] homepage = "https://github.com/pyvisa/pyvisa-py" diff --git a/pyvisa_py/protocols/rpc.py b/pyvisa_py/protocols/rpc.py index 9944dc85..143934a7 100644 --- a/pyvisa_py/protocols/rpc.py +++ b/pyvisa_py/protocols/rpc.py @@ -629,6 +629,46 @@ def dummy(): self.reply_handler(reply, fromaddr) return replies + def send_call(self, proc, args, pack_func): + if pack_func is None and args is not None: + raise TypeError("non-null args with null pack_func") + self.start_call(proc) + if pack_func: + pack_func(args) + call = self.packer.get_buf() + _sendto(self.sock, call, (self.host, self.port)) + + def recv_call(self, unpack_func): + BUFSIZE = 8192 # Max UDP buffer size (for reply) + replies = [] + if unpack_func is None: + + def dummy(): + pass + + unpack_func = dummy + while 1: + r, w, x = [self.sock], [], [] + if select: + if self.timeout is None: + r, w, x = select.select(r, w, x) + else: + r, w, x = select.select(r, w, x, self.timeout) + if self.sock not in r: + break + reply, fromaddr = self.sock.recvfrom(BUFSIZE) + u = self.unpacker + u.reset(reply) + xid, verf = u.unpack_replyheader() + if xid != self.lastxid: + continue + reply = unpack_func() + self.unpacker.done() + replies.append((reply, fromaddr)) + if self.reply_handler: + self.reply_handler(reply, fromaddr) + return replies + # Port mapper interface @@ -729,6 +769,18 @@ def get_port(self, mapping): self.unpacker.unpack_uint, ) + def send_port(self, mapping): + return self.send_call( + PortMapperVersion.get_port, + mapping, + self.packer.pack_mapping, + ) + + def recv_port(self, mapping): + return self.recv_call( + self.unpacker.unpack_uint, + ) + def dump(self): return self.make_call( PortMapperVersion.dump, None, None, self.unpacker.unpack_pmaplist diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 71480da7..0923372c 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -9,9 +9,16 @@ import random import select import socket +import ipaddress import time from typing import Any, List, Optional, Tuple +# Let psutil be optional dependency +try: + import psutil +except: + psutil = None + from pyvisa import attributes, constants, errors, rname from pyvisa.constants import ResourceAttribute, StatusCode @@ -64,8 +71,48 @@ class TCPIPInstrSession(Session): @staticmethod def list_resources() -> List[str]: - # TODO: is there a way to get this? - return [] + broadcast_addr = [] + if psutil is not None: + # Get broadcast address for each interface + for interface, snics in psutil.net_if_addrs().items(): + for snic in snics: + if snic.family is socket.AF_INET: + addr = snic.address + mask = snic.netmask + network = ipaddress.IPv4Network(addr + "/" + mask, strict=False) + broadcast_addr.append(str(network.broadcast_address)) + else: + msg = "To discover devices on all interfaces, please install psutil" + Session.register_unavailable(constants.InterfaceType.tcpip, "INSTR", msg) + broadcast_addr.append("255.255.255.255") + + try: + pmap_list = [rpc.BroadcastUDPPortMapperClient(ip) for ip in broadcast_addr] + for pmap in pmap_list: + pmap.set_timeout(0) + pmap.send_port( + (vxi11.DEVICE_CORE_PROG, vxi11.DEVICE_CORE_VERS, rpc.IPPROTO_TCP, 0) + ) + + # Timeout for responses + time.sleep(1) + + all_res = [] + for pmap in pmap_list: + resp = pmap.recv_port( + (vxi11.DEVICE_CORE_PROG, vxi11.DEVICE_CORE_VERS, rpc.IPPROTO_TCP, 0) + ) + res = [r[1][0] for r in resp if r[0] > 0] + res = sorted( + res, key=lambda ip: tuple(int(part) for part in ip.split(".")) + ) + # TODO: Detect GPIB over TCPIP + res = ["TCPIP::{}::INSTR".format(host) for host in res] + all_res.extend(res) + except rpc.RPCError: + return [] + + return all_res def after_parsing(self) -> None: # TODO: board_number not handled diff --git a/pyvisa_py/testsuite/keysight_assisted_tests/test_resource_manager.py b/pyvisa_py/testsuite/keysight_assisted_tests/test_resource_manager.py index 7cb10c9a..50f5f57f 100644 --- a/pyvisa_py/testsuite/keysight_assisted_tests/test_resource_manager.py +++ b/pyvisa_py/testsuite/keysight_assisted_tests/test_resource_manager.py @@ -3,7 +3,12 @@ """ import pytest -from pyvisa.testsuite.keysight_assisted_tests import copy_func, require_virtual_instr +from pyvisa.rname import ResourceName +from pyvisa.testsuite.keysight_assisted_tests import ( + RESOURCE_ADDRESSES, + copy_func, + require_virtual_instr, +) from pyvisa.testsuite.keysight_assisted_tests.test_resource_manager import ( TestResourceManager as BaseTestResourceManager, TestResourceParsing as BaseTestResourceParsing, @@ -14,9 +19,15 @@ class TestPyResourceManager(BaseTestResourceManager): """ """ - test_list_resource = pytest.mark.xfail( - copy_func(BaseTestResourceManager.test_list_resource) - ) + def test_list_resource(self): + """Test listing the available resources. + The bot supports only TCPIP and of those resources we expect to be able + to list only INSTR resources not SOCKET. + """ + # Default settings + resources = self.rm.list_resources() + for v in (v for v in RESOURCE_ADDRESSES.values() if v.endswith("INSTR")): + assert str(ResourceName.from_string(v)) in resources test_last_status = pytest.mark.xfail( copy_func(BaseTestResourceManager.test_last_status) From 662e0f1b69bd2ba3908af019944a1691b13a2b13 Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Fri, 10 Jun 2022 14:39:26 -0700 Subject: [PATCH 2/6] fix import order for flake8 --- docs/source/conf.py | 2 +- pyvisa_py/tcpip.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1e8b0e2a..3177965c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,9 +10,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. +import datetime import os import sys -import datetime if sys.version_info >= (3, 8): from importlib.metadata import version as get_version diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 0923372c..2720f911 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -6,17 +6,17 @@ :license: MIT, see LICENSE for more details. """ +import ipaddress import random import select import socket -import ipaddress import time from typing import Any, List, Optional, Tuple # Let psutil be optional dependency try: import psutil -except: +except ImportError: psutil = None from pyvisa import attributes, constants, errors, rname From 1d298834f544cc9c8e1915ac90b3e1f41f17d0c6 Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Sat, 11 Jun 2022 21:19:54 -0700 Subject: [PATCH 3/6] fix mypy typing error --- pyvisa_py/tcpip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 2720f911..3cc07b6b 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -15,7 +15,7 @@ # Let psutil be optional dependency try: - import psutil + import psutil # type: ignore except ImportError: psutil = None From 971fae0706f5d7312c23ee38e560388e6b06e618 Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Tue, 28 Jun 2022 10:36:10 -0700 Subject: [PATCH 4/6] remove resource unavailability without psutil --- pyvisa_py/tcpip.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 3cc07b6b..78bcf563 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -82,8 +82,7 @@ def list_resources() -> List[str]: network = ipaddress.IPv4Network(addr + "/" + mask, strict=False) broadcast_addr.append(str(network.broadcast_address)) else: - msg = "To discover devices on all interfaces, please install psutil" - Session.register_unavailable(constants.InterfaceType.tcpip, "INSTR", msg) + # If psutil unavailable fallback to default interface broadcast_addr.append("255.255.255.255") try: From 98ad544ee1523e849f2262cbc3ebb02886cda1d8 Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Tue, 28 Jun 2022 10:43:54 -0700 Subject: [PATCH 5/6] add error handling for send/recieve UDP --- pyvisa_py/protocols/rpc.py | 10 ++++++++-- pyvisa_py/tcpip.py | 24 ++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/pyvisa_py/protocols/rpc.py b/pyvisa_py/protocols/rpc.py index 143934a7..d7fe4926 100644 --- a/pyvisa_py/protocols/rpc.py +++ b/pyvisa_py/protocols/rpc.py @@ -636,7 +636,10 @@ def send_call(self, proc, args, pack_func): if pack_func: pack_func(args) call = self.packer.get_buf() - _sendto(self.sock, call, (self.host, self.port)) + try: + _sendto(self.sock, call, (self.host, self.port)) + except OSError as exc: + raise RPCError("unable to send broadcast") from exc def recv_call(self, unpack_func): BUFSIZE = 8192 # Max UDP buffer size (for reply) @@ -656,7 +659,10 @@ def dummy(): r, w, x = select.select(r, w, x, self.timeout) if self.sock not in r: break - reply, fromaddr = self.sock.recvfrom(BUFSIZE) + try: + reply, fromaddr = self.sock.recvfrom(BUFSIZE) + except OSError as exc: + raise RPCError("unable to recieve broadcast") from exc u = self.unpacker u.reset(reply) xid, verf = u.unpack_replyheader() diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 78bcf563..d72428ce 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -85,22 +85,28 @@ def list_resources() -> List[str]: # If psutil unavailable fallback to default interface broadcast_addr.append("255.255.255.255") - try: - pmap_list = [rpc.BroadcastUDPPortMapperClient(ip) for ip in broadcast_addr] - for pmap in pmap_list: - pmap.set_timeout(0) + pmap_list = [rpc.BroadcastUDPPortMapperClient(ip) for ip in broadcast_addr] + for pmap in list(pmap_list): + pmap.set_timeout(0) + try: pmap.send_port( (vxi11.DEVICE_CORE_PROG, vxi11.DEVICE_CORE_VERS, rpc.IPPROTO_TCP, 0) ) + except rpc.RPCError: + pmap_list.remove(pmap) - # Timeout for responses - time.sleep(1) + # Timeout for responses + time.sleep(1) - all_res = [] - for pmap in pmap_list: + all_res = [] + for pmap in pmap_list: + try: resp = pmap.recv_port( (vxi11.DEVICE_CORE_PROG, vxi11.DEVICE_CORE_VERS, rpc.IPPROTO_TCP, 0) ) + except rpc.RPCError: + pass + else: res = [r[1][0] for r in resp if r[0] > 0] res = sorted( res, key=lambda ip: tuple(int(part) for part in ip.split(".")) @@ -108,8 +114,6 @@ def list_resources() -> List[str]: # TODO: Detect GPIB over TCPIP res = ["TCPIP::{}::INSTR".format(host) for host in res] all_res.extend(res) - except rpc.RPCError: - return [] return all_res From 5a72ad2276a457b9a9f6ddbede1af7113ea9c341 Mon Sep 17 00:00:00 2001 From: Reid Sox-Harris Date: Tue, 28 Jun 2022 12:17:14 -0700 Subject: [PATCH 6/6] add psutil optional dependency to README --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 13543965..3a8f6e98 100644 --- a/README.rst +++ b/README.rst @@ -64,11 +64,13 @@ Requirements - Python (tested with 3.6+) - PyVISA 1.11+ -Optionally +Optionally: + - PySerial (to interface with Serial instruments) - PyUSB (to interface with USB instruments) - linux-gpib (to interface with gpib instruments, only on linux) - gpib-ctypes (to interface with GPIB instruments on Windows and Linux) +- psutil (to discover TCPIP devices across multiple interfaces) Installation