Skip to content

feat: Add MockHandler for hardware-less simulation#269

Open
hitarthium wants to merge 3 commits intofossasia:mainfrom
hitarthium:main
Open

feat: Add MockHandler for hardware-less simulation#269
hitarthium wants to merge 3 commits intofossasia:mainfrom
hitarthium:main

Conversation

@hitarthium
Copy link

@hitarthium hitarthium commented Feb 8, 2026

Description
This PR introduces a MockHandler to the pslab-python library. It allows developers to initialize the ScienceLab class in a simulation mode (mock=True), enabling testing and development without a physical PSLab device connected.

Changes Made

  • New Module: Added pslab/mock_handler.py which mimics the ConnectionHandler interface.
  • Integration: Updated pslab/sciencelab.py to support a mock boolean argument in __init__.
  • Functionality: Implemented read, write, send_byte, and signal injection methods to allow programmatic control over "hardware" responses.
  • Compatibility: Addressed type handling to ensure smooth operation with existing instrument classes (e.g., Oscilloscope).

How to Test

from pslab.sciencelab import ScienceLab

# Initialize in mock mode
device = ScienceLab(mock=True)

# Verify firmware version is set to mock default
print(device.firmware) # Expected: 'MOCK_FW_1.0'

# Verify signal injection
device.device.register_response(b'\x01', b'\x99')
device.device.write(b'\x01')
print(device.device.read(1)) # Expected: b'\x99'

## Summary by Sourcery

Add a mock communication handler and hook it into ScienceLab to enable hardware-less simulation of the PSLab device.

New Features:
- Allow initializing ScienceLab in a mock mode via a new mock flag, using a simulated device instead of a physical connection.
- Introduce a MockHandler that simulates the ConnectionHandler interface, including read/write and basic data/ack handling, for use in tests and development.

- Implements MockHandler class with full I/O simulation.
- Updates ScienceLab to support 'mock=True' initialization.
- Fixes type handling for byte/int compatibility in simulation.
@sourcery-ai
Copy link

sourcery-ai bot commented Feb 8, 2026

Reviewer's Guide

Adds a MockHandler implementation and integrates a mock mode into ScienceLab, enabling hardware-less simulation and programmable device responses compatible with existing instrument classes.

Sequence diagram for ScienceLab initialization in mock mode

sequenceDiagram
    actor Developer
    participant ScienceLab
    participant MockHandler

    Developer->>ScienceLab: ScienceLab(device=None, mock=True)
    activate ScienceLab
    ScienceLab->>MockHandler: MockHandler()
    activate MockHandler
    MockHandler-->>ScienceLab: instance
    ScienceLab->>MockHandler: open()
    MockHandler-->>ScienceLab: connected=True
    ScienceLab-->>Developer: ScienceLab instance with
    deactivate MockHandler
    deactivate ScienceLab
Loading

Sequence diagram for registering and using a mock response

sequenceDiagram
    actor Developer
    participant ScienceLab
    participant MockHandler

    Developer->>ScienceLab: device.register_response(b"01", b"99")
    activate ScienceLab
    ScienceLab->>MockHandler: register_response(command=b"01", response=b"99")
    MockHandler-->>ScienceLab: response_map updated

    Developer->>ScienceLab: device.write(b"01")
    ScienceLab->>MockHandler: write(data=b"01")
    activate MockHandler
    MockHandler->>MockHandler: match command in response_map
    MockHandler->>MockHandler: read_buffer.extend(b"99")
    MockHandler-->>ScienceLab: bytes_written=1

    Developer->>ScienceLab: device.read(1)
    ScienceLab->>MockHandler: read(size=1)
    MockHandler->>MockHandler: pop 1 byte from read_buffer
    MockHandler-->>ScienceLab: b"99"
    ScienceLab-->>Developer: b"99"
    deactivate MockHandler
    deactivate ScienceLab
Loading

Class diagram for ScienceLab mock integration and MockHandler

classDiagram
    class ScienceLab {
        +ConnectionHandler device
        +str firmware
        +LogicAnalyzer logic_analyzer
        +Oscilloscope oscilloscope
        +WaveformGenerator waveform_generator
        +ScienceLab(device ConnectionHandler, mock bool)
    }

    class ConnectionHandler {
    }

    class MockHandler {
        +bool connected
        +str port
        +Dict~bytes,bytes~ response_map
        +bytearray read_buffer
        +Logger logger
        +MockHandler(port str)
        +open(port str) void
        +close() void
        +write(data bytes) int
        +read(size int) bytes
        +clear_buffer() void
        +send_byte(val int)
        +send_byte(val bytes)
        +send_int(val int) void
        +read_byte() int
        +read_int() int
        +get_ack() bool
        +register_response(command bytes, response bytes) void
        +inject_data(data bytes) void
    }

    class LogicAnalyzer {
    }

    class Oscilloscope {
    }

    class WaveformGenerator {
    }

    ScienceLab --> ConnectionHandler : uses
    ScienceLab --> MockHandler : uses when_mock_true
    ScienceLab --> LogicAnalyzer : composes
    ScienceLab --> Oscilloscope : composes
    ScienceLab --> WaveformGenerator : composes
    MockHandler ..|> ConnectionHandler : mimics_interface
Loading

File-Level Changes

Change Details Files
Add mock-mode initialization path to ScienceLab for hardware-less operation.
  • Extend ScienceLab.init to accept a mock boolean flag in addition to an optional device parameter.
  • Instantiate MockHandler, open the connection, and set a fixed mock firmware string when mock mode is enabled.
  • Retain existing autoconnect/device-based initialization path when mock is False for backward compatibility.
  • Pass the selected device (real or mock) into LogicAnalyzer, Oscilloscope, and WaveformGenerator initializations.
pslab/sciencelab.py
Introduce MockHandler implementation that simulates the ConnectionHandler interface for tests and development.
  • Create MockHandler class with open/close, write/read, and internal read buffer management to emulate a serial-like device.
  • Implement response registration via a response_map that maps outgoing commands to queued incoming responses, plus generic data injection into the read buffer.
  • Provide compatibility helpers send_byte, send_int, read_byte, read_int, and get_ack to align with expected ConnectionHandler behaviors.
  • Add logging for connection lifecycle and unmapped writes, and simple error handling when reading/writing while disconnected.
pslab/mock_handler.py

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

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `pslab/sciencelab.py:37-41` </location>
<code_context>
-    def __init__(self, device: ConnectionHandler | None = None):
-        self.device = device if device is not None else autoconnect()
-        self.firmware = self.device.get_firmware_version()
+    def __init__(self, device: ConnectionHandler | None = None, mock: bool = False):
+        if mock:
+            self.device = MockHandler()
+            self.device.open()
+            self.firmware = "MOCK_FW_1.0" # Fake firmware version
+        else:
+            self.device = device if device is not None else autoconnect()
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Clarify interaction between `device` and `mock` to avoid surprising behavior.

With `mock=True`, any provided `device` is ignored and a `MockHandler` is always used, which can hide misconfigurations. Consider either raising when both `device` and `mock` are set, or defining clear precedence (e.g., prefer `device` when given, otherwise use `MockHandler` when `mock=True`) and documenting it.
</issue_to_address>

### Comment 2
<location> `pslab/mock_handler.py:25-27` </location>
<code_context>
+        self.read_buffer = bytearray()
+        self.logger = logging.getLogger(__name__)
+
+    def open(self, port: Optional[str] = None) -> None:
+        self.connected = True
+        self.logger.info(f"MockHandler connected on {self.port}")
+
+    def close(self) -> None:
</code_context>

<issue_to_address>
**issue:** Use the `port` parameter in `open` or remove it to avoid confusion.

Right now `open` accepts `port` but ignores it and always logs `self.port`. If you want to mirror the real handler, consider updating `self.port` when a non-`None` value is passed (and log that), otherwise drop the parameter so the API isn’t misleading.
</issue_to_address>

### Comment 3
<location> `pslab/mock_handler.py:55-60` </location>
<code_context>
+        if not self.connected:
+            raise RuntimeError("Attempted to read from closed MockHandler.")
+
+        if len(self.read_buffer) < size:
+            self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
+            # Return what we have, then empty bytes
+            data = self.read_buffer[:]
+            self.read_buffer.clear()
+            return bytes(data)
+
+        data = self.read_buffer[:size]
</code_context>

<issue_to_address>
**suggestion:** Align partial-read behavior and inline comment in `read`, or make behavior closer to the real handler.

In this branch, the comment (`"Return what we have, then empty bytes"`) doesn’t match the behavior: we return once with the available data and clear the buffer. Also, returning fewer bytes than requested may not align with the real handler’s semantics (e.g., blocking until enough data is available or raising). Please either update the comment to describe the current behavior, or adjust the implementation to better match the real handler so callers can rely on consistent read semantics.

```suggestion
        if len(self.read_buffer) < size:
            self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
            # Return all currently available bytes and clear the buffer
            data = self.read_buffer[:]
            self.read_buffer.clear()
            return bytes(data)
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

- Updated MockHandler.open to use the port argument.
- Clarified partial read behavior in MockHandler.read.
- Added safety check in ScienceLab to prevent mixed device/mock arguments.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a mock/simulated connection path to pslab-python so ScienceLab can be instantiated without a physical PSLab device, enabling hardware-less development/testing.

Changes:

  • Add pslab/mock_handler.py implementing a mock communication handler with injectable responses/buffered reads.
  • Extend ScienceLab.__init__ with a mock flag to select the mock handler instead of autoconnect().
  • Provide basic read/write/send helpers and response registration utilities for simulation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 10 comments.

File Description
pslab/sciencelab.py Adds mock initialization path and wires ScienceLab to use MockHandler in simulation mode.
pslab/mock_handler.py New mock handler implementation intended to mimic the device connection interface with programmable responses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 37 to 54
def __init__(self, device: ConnectionHandler | None = None, mock: bool = False):
"""
Initialize the ScienceLab interface.

:param device: The connection handler (real hardware).
:param mock: If True, use the MockHandler for simulation.
:raises ValueError: If both 'device' and 'mock=True' are provided.
"""
if mock:
if device is not None:
raise ValueError("Cannot initialize ScienceLab with both a physical device and mock=True.")

self.device = MockHandler()
self.device.open()
self.firmware = "MOCK_FW_1.0" # Fake firmware version
else:
self.device = device if device is not None else autoconnect()
self.firmware = self.device.get_firmware_version()
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

New mock mode/MockHandler functionality is not covered by tests. The repo already has a substantial pytest suite; adding a small unit test that instantiates ScienceLab(mock=True), registers a response, writes a command, and asserts the read/ack behavior would prevent regressions and validate the intended hardware-less workflow.

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +121
def get_ack(self) -> bool:
"""Simulates receiving an acknowledgement from the device."""
return True
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

get_ack() returns bool, but the real ConnectionHandler.get_ack() returns an int bitfield and callers sometimes bit-shift it (e.g., >> 4 in I2C/NRF code) or test ACK bits. Returning True coerces to 1, which can change behavior. For compatibility, return an int ACK value consistent with the protocol (typically 0x01 for ACK), and consider matching the exception behavior on timeout if needed.

Suggested change
def get_ack(self) -> bool:
"""Simulates receiving an acknowledgement from the device."""
return True
def get_ack(self) -> int:
"""Simulates receiving an acknowledgement from the device as an integer bitfield."""
return 0x01

Copilot uses AI. Check for mistakes.
if port:
self.port = port
self.connected = True
self.logger.info(f"MockHandler connected on {self.port}")
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

PEP 8 spacing: add a blank line between open() and close() method definitions to keep method boundaries clear and satisfy formatters/linters.

Suggested change
self.logger.info(f"MockHandler connected on {self.port}")
self.logger.info(f"MockHandler connected on {self.port}")

Copilot uses AI. Check for mistakes.
Comment on lines +9 to 11
from .mock_handler import MockHandler
import time
from typing import Iterable, List
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

Import ordering: relative/local import from .mock_handler import MockHandler is placed before standard-library imports (time, typing). The rest of the file groups stdlib imports first, then pslab imports; please move this import to the existing pslab.* import block to keep import grouping consistent (and to satisfy common linters).

Copilot uses AI. Check for mistakes.
Comment on lines 49 to 54
self.device = MockHandler()
self.device.open()
self.firmware = "MOCK_FW_1.0" # Fake firmware version
else:
self.device = device if device is not None else autoconnect()
self.firmware = self.device.get_firmware_version()
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

In mock mode, self.firmware is set to a string ("MOCK_FW_1.0"), but the rest of the codebase treats ScienceLab.firmware as a FirmwareVersion (e.g., self.firmware.major is used later in this file and elsewhere). Initializing with mock=True will raise AttributeError when any code checks .major/.minor/.patch. Consider setting self.firmware to a FirmwareVersion instance (and optionally mirroring it onto self.device.firmware if other components expect it).

Copilot uses AI. Check for mistakes.
Comment on lines 38 to 44
"""
Initialize the ScienceLab interface.

:param device: The connection handler (real hardware).
:param mock: If True, use the MockHandler for simulation.
:raises ValueError: If both 'device' and 'mock=True' are provided.
"""
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

The new __init__ docstring uses reST :param/:raises fields, but the repository enforces NumPy-style docstrings (see [pydocstyle] convention = numpy in tox.ini, and existing docstrings in this file using Parameters/Returns). This will likely fail docstyle checks; please convert this docstring to NumPy style to match the codebase convention.

Copilot uses AI. Check for mistakes.
raise ValueError("Cannot initialize ScienceLab with both a physical device and mock=True.")

self.device = MockHandler()
self.device.open()
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

ScienceLab uses ConnectionHandler (connect/disconnect) semantics elsewhere, but mock mode calls MockHandler.open(). To keep the handler interchangeable with ConnectionHandler (and to reduce special-casing), consider implementing connect()/disconnect() on MockHandler and calling connect() here instead of open() (or at least provide connect = open / disconnect = close aliases).

Suggested change
self.device.open()
# Ensure MockHandler provides a ConnectionHandler-like API
if not hasattr(self.device, "connect") and hasattr(self.device, "open"):
self.device.connect = self.device.open # type: ignore[attr-defined]
if not hasattr(self.device, "disconnect") and hasattr(self.device, "close"):
self.device.disconnect = self.device.close # type: ignore[attr-defined]
self.device.connect()

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +83
if len(self.read_buffer) < size:
self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
# Return what we have, then empty bytes
data = self.read_buffer[:]
self.read_buffer.clear()
return bytes(data)

data = self.read_buffer[:size]
self.read_buffer = self.read_buffer[size:]
return bytes(data)

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

MockHandler.read() contains duplicated logic after an unconditional return (the block starting at line 73 is unreachable). This is dead code and can hide bugs; please remove the unreachable duplicate branch and keep a single, well-defined partial-read behavior.

Suggested change
if len(self.read_buffer) < size:
self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
# Return what we have, then empty bytes
data = self.read_buffer[:]
self.read_buffer.clear()
return bytes(data)
data = self.read_buffer[:size]
self.read_buffer = self.read_buffer[size:]
return bytes(data)

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +17
class MockHandler:
"""
A mock communication handler that simulates the PSLab hardware.
"""

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

MockHandler is described as mimicking the ConnectionHandler interface, but it does not currently implement the same surface area: ConnectionHandler requires connect()/disconnect() and provides helpers like get_byte()/get_int() that the instrument code calls extensively. As written, passing MockHandler into instruments will fail at runtime when they call _device.get_int() / _device.get_byte() (e.g., oscilloscope, multimeter, logic analyzer). Consider subclassing pslab.connection.connection.ConnectionHandler and implementing connect/disconnect + read/write, or add compatible aliases (connect=open, disconnect=close, get_byte=read_byte, get_int=read_int, etc.) so instruments can run unchanged.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +83
if len(self.read_buffer) < size:
self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
# Return what we have, then empty bytes
data = self.read_buffer[:]
self.read_buffer.clear()
return bytes(data)

data = self.read_buffer[:size]
self.read_buffer = self.read_buffer[size:]
return bytes(data)

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

This statement is unreachable.

Suggested change
if len(self.read_buffer) < size:
self.logger.warning(f"MockHandler: Requested {size} bytes, but only {len(self.read_buffer)} available.")
# Return what we have, then empty bytes
data = self.read_buffer[:]
self.read_buffer.clear()
return bytes(data)
data = self.read_buffer[:size]
self.read_buffer = self.read_buffer[size:]
return bytes(data)

Copilot uses AI. Check for mistakes.
Copy link
Member

@mariobehling mariobehling left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. A hardware less mock mode is a useful direction for testing and onboarding.

Before we can review this for merge, we need a few things:

  1. Please open an issue describing the intended behaviour and scope, then link it in the PR description using standard GitHub best practice, for example:
    Fixes #

  2. There is a functional blocker: in mock mode ScienceLab.firmware is set to a plain string, but the codebase treats it as a FirmwareVersion like object and accesses .major/.minor/.patch. Please change mock mode to return a proper FirmwareVersion instance (and keep behaviour consistent with the non mock path).

  3. Please match the repo docstring style. The new __init__ docstring uses :param syntax, but we use NumPy style docstrings. Please convert it to the existing convention.

  4. Please attach a short screencast showing this working on your screen:

  • how you run it
  • a minimal snippet using ScienceLab(mock=True)
  • at least one instrument call that exercises the mock handler
  • your full test command output (pytest or tox)

Once the issue is linked, the firmware type is fixed, the docstring matches conventions, and we have a screencast proving the main path works, we can continue with a maintainer review.

@mariobehling
Copy link
Member

Small process note.

We have automatic Copilot PR reviews enabled on this repository. These reviews are only triggered if the contributor has GitHub Copilot enabled and an active license on their own account.

Please enable Copilot in your GitHub settings if you have access. In many regions, free licenses are available through educational institutions or developer programs. Enabling Copilot helps us speed up the auto review process and reduces manual review overhead for the core team.

The team will review your PR. Thank you for your contribution and cooperation.

- Replaced string firmware with MockFirmwareVersion object.
- Updated docstrings to NumPy style.
- Fixed get_ack return type to int.
- Cleaned up read() method.
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.

2 participants