Skip to content

Commit

Permalink
4.5.0 - Add base SCPI driver class
Browse files Browse the repository at this point in the history
  • Loading branch information
Vaughn Kottler authored and Vaughn Kottler committed Jun 11, 2024
1 parent 622dc50 commit 7c4e94b
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 32 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions local/variables/package.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
major: 4
minor: 4
patch: 6
minor: 5
patch: 0
entry: runtimepy
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions runtimepy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# =====================================
# generator=datazen
# version=3.1.4
# hash=74c249f8b5e7fc3e367e3617d368f819
# hash=9539398893e7a420adf97d118b6bcabf
# =====================================

"""
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions runtimepy/data/factories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
28 changes: 19 additions & 9 deletions runtimepy/net/tcp/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

LOG = _getLogger(__name__)
T = _TypeVar("T", bound="TcpConnection")
V = _TypeVar("V", bound="TcpConnection")
ConnectionCallback = _Callable[[T], None]


Expand Down Expand Up @@ -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."""
Expand Down
37 changes: 29 additions & 8 deletions tasks/scpi.py → runtimepy/net/tcp/scpi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
7 changes: 0 additions & 7 deletions tasks/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 60 additions & 0 deletions tests/net/tcp/test_scpi.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 7c4e94b

Please sign in to comment.