From 7c4e94bbbc15dc14447313377a8a17c32426b4d7 Mon Sep 17 00:00:00 2001 From: Vaughn Kottler Date: Tue, 11 Jun 2024 11:00:24 -0700 Subject: [PATCH] 4.5.0 - Add base SCPI driver class --- .github/workflows/python-package.yml | 2 +- README.md | 4 +- local/variables/package.yaml | 4 +- pyproject.toml | 2 +- runtimepy/__init__.py | 4 +- runtimepy/data/factories.yaml | 2 + runtimepy/net/tcp/connection.py | 28 ++++++--- .../net/tcp/scpi/__init__.py | 37 +++++++++--- tasks/default.yaml | 7 --- tests/net/tcp/test_scpi.py | 60 +++++++++++++++++++ 10 files changed, 118 insertions(+), 32 deletions(-) rename tasks/scpi.py => runtimepy/net/tcp/scpi/__init__.py (54%) create mode 100644 tests/net/tcp/test_scpi.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4ac8e145..7b4cb0ac 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -72,7 +72,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=4.4.6 + repo=runtimepy version=4.5.0 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index d456001f..421fe625 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=5899579d4e556fbe17d86593bd0e8e84 + hash=48a3836c8cab67b925fe019f3db34c8d ===================================== --> -# runtimepy ([4.4.6](https://pypi.org/project/runtimepy/)) +# runtimepy ([4.5.0](https://pypi.org/project/runtimepy/)) [![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/) ![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg) diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 8fceb7af..175b2006 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 4 -minor: 4 -patch: 6 +minor: 5 +patch: 0 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index cb2ef72b..276af13c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "4.4.6" +version = "4.5.0" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index c6ecc730..b8e99d1f 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=74c249f8b5e7fc3e367e3617d368f819 +# hash=9539398893e7a420adf97d118b6bcabf # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "4.4.6" +VERSION = "4.5.0" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/data/factories.yaml b/runtimepy/data/factories.yaml index f185bd9d..c1291525 100644 --- a/runtimepy/data/factories.yaml +++ b/runtimepy/data/factories.yaml @@ -25,6 +25,8 @@ factories: - {name: runtimepy.net.factories.RuntimepyWebsocketJson} - {name: runtimepy.net.factories.RuntimepyWebsocketData} + - {name: runtimepy.net.tcp.scpi.ScpiConn} + # Useful tasks. - {name: runtimepy.task.trig.Sinusoid} - {name: runtimepy.task.sample.Sample} diff --git a/runtimepy/net/tcp/connection.py b/runtimepy/net/tcp/connection.py index abfcdb4c..4e41b0e2 100644 --- a/runtimepy/net/tcp/connection.py +++ b/runtimepy/net/tcp/connection.py @@ -36,6 +36,7 @@ LOG = _getLogger(__name__) T = _TypeVar("T", bound="TcpConnection") +V = _TypeVar("V", bound="TcpConnection") ConnectionCallback = _Callable[[T], None] @@ -191,29 +192,38 @@ def app_cb(conn: T) -> None: @classmethod @_asynccontextmanager - async def create_pair(cls: type[T]) -> _AsyncIterator[tuple[T, T]]: + async def create_pair( + cls: type[T], peer: type[V] = None + ) -> _AsyncIterator[tuple[V, T]]: """Create a connection pair.""" cond = _Semaphore(0) - conn1: _Optional[T] = None + server_conn: _Optional[V] = None - def callback(conn: T) -> None: + def callback(conn: V) -> None: """Signal the semaphore.""" - nonlocal conn1 - conn1 = conn + nonlocal server_conn + server_conn = conn cond.release() async with _AsyncExitStack() as stack: + # Use the same class for the server end by default. + if peer is None: + peer = cls # type: ignore + assert peer is not None + server = await stack.enter_async_context( - cls.serve(callback, port=0, backlog=1) + peer.serve(callback, port=0, backlog=1) ) host = server.sockets[0].getsockname() - conn2 = await cls.create_connection(host="localhost", port=host[1]) + client = await cls.create_connection( + host="localhost", port=host[1] + ) await cond.acquire() - assert conn1 is not None - yield conn1, conn2 + assert server_conn is not None + yield server_conn, client async def close(self) -> None: """Close this connection.""" diff --git a/tasks/scpi.py b/runtimepy/net/tcp/scpi/__init__.py similarity index 54% rename from tasks/scpi.py rename to runtimepy/net/tcp/scpi/__init__.py index b4c14f83..57397ada 100644 --- a/tasks/scpi.py +++ b/runtimepy/net/tcp/scpi/__init__.py @@ -22,14 +22,13 @@ def init(self) -> None: async def async_init(self) -> bool: """Initialize this instance.""" - self.logger.info(await self.send_command("*IDN?")) - - return True + # Any SCPI device should respond to this query. + return bool(await self.send_command("*IDN", log=True, query=True)) async def process_text(self, data: str) -> bool: """Process a text frame.""" - for item in data.split("\r\n"): + for item in data.splitlines(): if item: await self.message_queue.put(item) @@ -39,15 +38,37 @@ async def process_binary(self, data: bytes) -> bool: """Process a binary frame.""" return await self.process_text(data.decode()) - async def send_command(self, command: str, response: bool = True) -> str: + async def send_command( + self, + command: str, + response: bool = True, + log: bool = False, + query: bool = False, + timeout: float = 1.0, + ) -> str: """Send a command.""" + result = "" + + if query: + command += "?" + async with self.command_lock: self.send_text(command + "\n") - result = "" - if response: - result = await self.message_queue.get() + if response or query: + try: + result = await asyncio.wait_for( + self.message_queue.get(), timeout + ) + if log: + self.logger.info("(%s) %s", command, result) + except asyncio.TimeoutError: + self.logger.error( + "Peer didn't respond to '%s'! (timeout: %.2f)", + command, + timeout, + ) return result diff --git a/tasks/default.yaml b/tasks/default.yaml index ffe60203..576fcfbd 100644 --- a/tasks/default.yaml +++ b/tasks/default.yaml @@ -3,12 +3,5 @@ includes_left: - package://runtimepy/server.yaml - package://runtimepy/server_dev.yaml -factories: - - {name: tasks.scpi.ScpiConn} -clients: - - factory: scpi_conn - name: siglent - kwargs: {host: siglent_psu1, port: 5025} - app: - runtimepy.net.apps.wait_for_stop diff --git a/tests/net/tcp/test_scpi.py b/tests/net/tcp/test_scpi.py new file mode 100644 index 00000000..3013f74d --- /dev/null +++ b/tests/net/tcp/test_scpi.py @@ -0,0 +1,60 @@ +""" +Test the 'net.tcp.scpi' module. +""" + +# built-in +import asyncio + +# third-party +from pytest import mark + +# module under test +from runtimepy.net.tcp import TcpConnection +from runtimepy.net.tcp.scpi import ScpiConnection + + +class MockScpiConnection(TcpConnection): + """A sample connection class.""" + + async def process_binary(self, data: bytes) -> bool: + """Process a binary frame.""" + return await self.process_text(data.decode()) + + async def process_text(self, data: str) -> bool: + """Process a text frame.""" + + for item in data.splitlines(): + match item: + case "*IDN?": + self.send_text("Mock,Device,1.0") + case _: + self.logger.error("Didn't handle message '%s'.", item) + + return True + + +@mark.asyncio +async def test_scpi_connection_basic(): + """Test basic interactions with a SCPI connection pair.""" + + async with ScpiConnection.create_pair(peer=MockScpiConnection) as ( + server, + client, + ): + event = asyncio.Event() + + # Start connection processing. + processes = [ + asyncio.create_task(server.process(stop_sig=event)), + asyncio.create_task(client.process(stop_sig=event)), + ] + + # Initialize client. + await client.initialized.wait() + + # We don't expect a response. + await client.send_command("asdf", query=True, log=True, timeout=0.01) + + # End test. + event.set() + await asyncio.gather(*processes)