Skip to content

feat: add Pico SCPI transport - #286

Open
IM-TechieScientist wants to merge 1 commit into
fossasia:mainfrom
IM-TechieScientist:pico-scpi-transport
Open

feat: add Pico SCPI transport#286
IM-TechieScientist wants to merge 1 commit into
fossasia:mainfrom
IM-TechieScientist:pico-scpi-transport

Conversation

@IM-TechieScientist

@IM-TechieScientist IM-TechieScientist commented Jul 28, 2026

Copy link
Copy Markdown

First PR adding support for PSLab Pico in pslab-python. Working on #285.

This PR introduces the initial pslab.pico package with:

  • USB CDC SCPI transport for direct Pico communication
  • TCP SCPI transport for the ESP32-C3 Wi-Fi bridge
  • Shared ScpiClient helper for commands, queries, binary SCPI blocks and error queue access
  • PicoDevice entry point for future Pico instruments
  • Top level from pslab import PicoDevice export
  • Unit tests for SCPI text queries, silent commands, arbitrary block parsing, transport helpers and device wrapping

Summary by Sourcery

Add initial PSLab Pico SCPI support, including transports, client helper, and high-level device wrapper.

New Features:

  • Introduce PicoDevice entry point with USB, Wi-Fi, and custom-transport constructors and context manager support.
  • Add PicoUsbTransport and PicoWifiTransport implementations for SCPI communication with PSLab Pico firmware.
  • Add ScpiClient helper for SCPI commands, queries, arbitrary block transfers, and error/status handling.
  • Expose PicoDevice and Pico SCPI helpers from the top-level pslab package.

Tests:

  • Add unit tests covering SCPI text queries, commands, arbitrary block parsing, transport helper commands, and PicoDevice wrapping.

Copilot AI review requested due to automatic review settings July 28, 2026 11:31

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @IM-TechieScientist, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sourcery-ai

sourcery-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds initial PSLab Pico SCPI support, including USB and Wi-Fi transports, a shared SCPI client, a high-level PicoDevice wrapper, top-level exports, and comprehensive unit tests around SCPI command/query behavior and transport helpers.

Sequence diagram for PicoDevice USB identification flow

sequenceDiagram
    actor User
    participant PicoDevice
    participant ScpiClient
    participant PicoUsbTransport
    participant Serial

    User->>PicoDevice: usb(port, baudrate, timeout)
    activate PicoDevice
    PicoDevice->>PicoUsbTransport: __init__(port, baudrate, timeout)
    PicoDevice->>ScpiClient: __init__(transport)
    deactivate PicoDevice

    User->>PicoDevice: connect()
    activate PicoDevice
    PicoDevice->>ScpiClient: connect()
    activate ScpiClient
    ScpiClient->>PicoUsbTransport: connect()
    activate PicoUsbTransport
    PicoUsbTransport->>Serial: Serial(port, baudrate, timeout)
    Serial-->>PicoUsbTransport: serial_instance
    deactivate PicoUsbTransport
    deactivate ScpiClient
    deactivate PicoDevice

    User->>PicoDevice: identify()
    activate PicoDevice
    PicoDevice->>ScpiClient: identify()
    activate ScpiClient
    ScpiClient->>ScpiClient: query(*IDN?)
    ScpiClient->>PicoUsbTransport: write(b"*IDN?\n")
    ScpiClient->>PicoUsbTransport: readline()
    PicoUsbTransport-->>ScpiClient: b"PSLab Pico...\n"
    ScpiClient-->>PicoDevice: id_string
    deactivate ScpiClient
    PicoDevice-->>User: id_string
    deactivate PicoDevice
Loading

File-Level Changes

Change Details Files
Introduce SCPI transport layer for PSLab Pico over USB CDC and TCP Wi-Fi bridge.
  • Define PicoTransport abstract interface with basic byte-stream operations and optional flush/reset hooks.
  • Implement PicoUsbTransport using pyserial, including VID/PID-based auto-discovery helpers and lazy connection management.
  • Implement PicoWifiTransport over TCP sockets with internal receive buffering, timeout handling, and simple line/block reads.
pslab/pico/transport.py
Add a reusable line-oriented ScpiClient with support for text queries, SCPI definite-length blocks, and error/status helpers.
  • Implement query, command, and query_block APIs, enforcing that command() is not used for queries.
  • Parse SCPI definite-length arbitrary blocks, including error-path handling when header marker is missing or malformed and draining block terminators.
  • Provide convenience helpers for identification, reset/clear, error queue access, and Wi-Fi transport selection/status.
pslab/pico/transport.py
Create PicoDevice high-level entry point that owns a ScpiClient and exposes convenience constructors and methods.
  • Add PicoDevice.usb and PicoDevice.wifi constructors that wrap PicoUsbTransport and PicoWifiTransport respectively.
  • Add PicoDevice.from_transport to allow injecting custom transports (e.g., for tests or alternative backends).
  • Expose basic device operations (identify/reset/status/transport selection) and implement context manager protocol for connect/close lifecycle.
pslab/pico/device.py
Expose Pico PicoDevice and SCPI helpers from the pslab package for external consumers.
  • Create pslab.pico package init to export PicoDevice, transports, ScpiClient, and SCPI error types.
  • Update top-level pslab.all to include PicoDevice so from pslab import PicoDevice works.
pslab/pico/__init__.py
pslab/__init__.py
Add unit tests covering SCPI client behavior, transport helpers, and PicoDevice integration using a fake transport.
  • Implement FakeTransport to simulate SCPI protocol responses and arbitrary block payloads.
  • Test query/command behaviors, including newline handling and rejection of queries via command().
  • Test query_block happy-path and SCPI error-path behavior, error parsing, Wi-Fi status parsing, and PicoDevice context manager wiring.
tests/test_pico_scpi_transport.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

pslab/pico/transport.py:190

  • Similar to read(), PicoWifiTransport.readline() will currently raise a raw socket.timeout/TimeoutError on timeouts, which makes error handling inconsistent with ScpiClient.query()/_read_exact() (which raise ScpiTimeoutError). Catch and rethrow as ScpiTimeoutError.
    def readline(self) -> bytes:
        self.connect()
        while b"\n" not in self._rx_buffer:
            chunk = self._socket.recv(4096)
            if not chunk:
                raise ScpiTimeoutError("SCPI TCP connection closed.")
            self._rx_buffer.extend(chunk)
        end = self._rx_buffer.index(b"\n") + 1
        line = bytes(self._rx_buffer[:end])
        del self._rx_buffer[:end]
        return line

pslab/pico/transport.py:253

  • read_block() fails for SCPI arbitrary blocks that use the standard #0 (indefinite-length) header: digit_count becomes 0, then int(self._read_exact(0).decode(...)) raises a ValueError. This makes the block reader incompatible with instruments/firmware that emit #0...\n blocks.
        digit_count_text = self._read_exact(1)
        try:
            digit_count = int(digit_count_text.decode("ascii"))
            byte_count = int(self._read_exact(digit_count).decode("ascii"))
        except ValueError as exc:
            raise ScpiError("Invalid SCPI arbitrary block header.") from exc

pslab/pico/transport.py:336

  • _drain_block_terminator() consumes one byte even when it is not a newline/CR. That byte is then lost, so the “next read” cannot reliably detect the protocol mismatch (it will already be desynchronized). It’s safer to fail fast if the terminator isn’t present.
        if char in (b"\n", b"\r"):
            return
        # The firmware emits a newline after blocks. If a different byte appears,
        # leave higher-level code to catch the protocol mismatch on the next read.

pslab/pico/transport.py:298

  • set_transport() accepts "WIRELESS" but then sends it verbatim (COMM:TRAN WIRELESS). If the firmware command set only supports USB, WIFI, and AUTO (as the docstring suggests), this will produce an invalid SCPI command. Consider accepting "WIRELESS" as an alias but mapping it to WIFI on the wire.
    def set_transport(self, mode: str) -> None:
        """Select firmware capture transport: ``USB``, ``WIFI``, or ``AUTO``."""

        normalized = mode.strip().upper()
        if normalized not in ("USB", "WIFI", "WIRELESS", "AUTO"):
            raise ValueError("mode must be USB, WIFI, WIRELESS, or AUTO.")
        self.command(f"COMM:TRAN {normalized}")

pslab/pico/transport.py:178

  • PicoWifiTransport.read() treats a remote close as ScpiTimeoutError, but a real socket timeout currently bubbles up as socket.timeout/TimeoutError (different exception type than the rest of the SCPI stack). Also, read(0) will read 1 byte because of max(1, ...). Handle size <= 0 and normalize timeouts to ScpiTimeoutError for consistent behavior.

This issue also appears on line 180 of the same file.

    def read(self, size: int) -> bytes:
        self.connect()
        while len(self._rx_buffer) < size:
            chunk = self._socket.recv(max(1, size - len(self._rx_buffer)))
            if not chunk:
                raise ScpiTimeoutError("SCPI TCP connection closed.")
            self._rx_buffer.extend(chunk)
        data = bytes(self._rx_buffer[:size])
        del self._rx_buffer[:size]
        return data

tests/test_pico_scpi_transport.py:91

  • Block parsing tests cover only definite-length blocks (#14...). Since the client is intended to support “arbitrary block parsing”, add a unit test for the standard #0...\n (indefinite-length) form so regressions are caught.
def test_query_block_reads_definite_length_payload():
    client = ScpiClient(FakeTransport())

    payload = client.query_block("LA:READ?")

    assert payload == b"abcd"

Comment thread pslab/pico/transport.py
def flush(self) -> None:
"""Flush pending output bytes if the backend supports it."""

def reset_input_buffer(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function used anywhere?

@IM-TechieScientist IM-TechieScientist Jul 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not at the moment but I felt it might be useful for when i implement waveform streaming to drop stale bytes before a new command group

@CloudyPadmal CloudyPadmal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What prevents us from reusing the existing ConnectionHandler? From what I can see, most of the functions here in PicoTransport are abstracted in the ConnectionHandler.

@IM-TechieScientist

Copy link
Copy Markdown
Author

I made a seperate PicoTransport as the existing connection handlers are tied to the old binary protocol during connection setup from what I understand.
I wanted to avoid accidentally mixing the old pslab binary handshake with the new SCPI transport.
However if it is preferred, I can rework the PR so the pico classes implement or reuse ConnectionHandler, while keeping the SCPI specific parsing in ScpiClient.

@Saksham-Sirohi Saksham-Sirohi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot flagged the following:

  • _drain_block_terminator() catches TimeoutError, but wifi transport raises ScpiTimeoutError and pyserial raises SerialTimeoutException — a missing terminator can leave the client out of sync.
  • command() / query() never check SYST:ERR / drain_errors() after operations.
  • close() could null out self._serial to avoid reuse of a closed handle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants