Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pslab/connection/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
from .connection import ConnectionHandler
from ._serial import SerialHandler
from .wlan import WLANHandler
from .mock import MockHandler

__all__ = [
"ConnectionHandler",
"SerialHandler",
"WLANHandler",
"autoconnect",
"MockHandler",
]


def detect() -> list[ConnectionHandler]:
Expand Down
123 changes: 123 additions & 0 deletions pslab/connection/mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Mock connection handler for PSLab.

This module provides a minimal in-memory `ConnectionHandler` implementation for
use in tests and development without physical PSLab hardware.
"""

from __future__ import annotations
from collections import deque
import pslab.protocol as CP
from pslab.connection.connection import ConnectionHandler


class MockHandler(ConnectionHandler):
"""In-memory mock implementation of `ConnectionHandler`.

The handler queues deterministic responses based on bytes written via
`write()` so higher-level code can be exercised without an actual device.
"""

def __init__(self, version: str = "PSLab V6 ", fw=(3, 0, 0)) -> None:
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The fw parameter is missing a type annotation. Consider adding tuple[int, int, int] to be consistent with other parameters and improve type safety.

Suggested change
def __init__(self, version: str = "PSLab V6 ", fw=(3, 0, 0)) -> None:
def __init__(self, version: str = "PSLab V6 ", fw: tuple[int, int, int] = (3, 0, 0)) -> None:

Copilot uses AI. Check for mistakes.
"""Initialize MockHandler.

Parameters
----------
version : str, optional
Version string to return when queried. Default is "PSLab V6 ".
fw : tuple of int, optional
Firmware version as a (major, minor, patch) tuple. Default is (3, 0, 0).
"""
self._rx = deque() # bytes to be read
self._tx = bytearray() # bytes written by client
self.version = version # convenient attribute for callers
self._fw = fw
Comment on lines +28 to +33
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The fw parameter is not validated. If non-integer values or values outside the valid byte range (0-255) are provided, it will cause an error later when constructing the response in _maybe_respond(). Consider adding validation in __init__ to ensure fw contains three integers in the valid range, or document this requirement in the docstring.

Suggested change
Firmware version as a (major, minor, patch) tuple. Default is (3, 0, 0).
"""
self._rx = deque() # bytes to be read
self._tx = bytearray() # bytes written by client
self.version = version # convenient attribute for callers
self._fw = fw
Firmware version as a (major, minor, patch) tuple of integers in the
range 0255. Default is (3, 0, 0).
"""
self._rx = deque() # bytes to be read
self._tx = bytearray() # bytes written by client
self.version = version # convenient attribute for callers
# Validate firmware version: must be three integers in byte range 0–255
if not isinstance(fw, (tuple, list)) or len(fw) != 3:
raise ValueError(
"fw must be a tuple or list of three integers in the range 0–255"
)
for part in fw:
if not isinstance(part, int) or not (0 <= part <= 255):
raise ValueError(
"fw must contain three integers in the range 0–255"
)
self._fw = tuple(fw)

Copilot uses AI. Check for mistakes.
self._connected = False
Comment on lines 20 to 34
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The __init__ method should have a Parameters section in its docstring following the NumPy docstring conventions used throughout the codebase. The version and fw parameters should be documented. For example:

Parameters

version : str, optional
Version string to return when queried. Default is "PSLab V6 ".
fw : tuple of int, optional
Firmware version tuple (major, minor, patch). Default is (3, 0, 0).

Copilot uses AI. Check for mistakes.

def connect(self) -> None:
"""Mark the handler as connected."""
self._connected = True
# Optional: validate the mock “device” by answering get_version
# self.version = self.get_version()

def disconnect(self) -> None:
"""Mark the handler as disconnected."""
self._connected = False

def read(self, numbytes: int) -> bytes:
"""Read bytes from the internal receive buffer.

Parameters
----------
numbytes : int
Number of bytes to read.

Returns
-------
bytes
Bytes read from the receive buffer (may be shorter if insufficient data
is available).
"""
out = bytearray()
while len(out) < numbytes and self._rx:
out.append(self._rx.popleft())
return bytes(out)
Comment on lines +46 to +63
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

When insufficient bytes are available in the receive buffer, this method returns fewer bytes than requested instead of blocking or raising an exception. This differs from the behavior of real connection handlers (which would timeout). Consider documenting this behavior, or optionally raising an exception when insufficient data is available to help catch test setup issues early.

Copilot uses AI. Check for mistakes.

def write(self, data: bytes) -> int:
"""Write bytes to the handler and queue any corresponding responses.

Parameters
----------
data : bytes
Bytes written by the caller.

Returns
-------
int
Number of bytes written.
"""
self._tx.extend(data)
self._maybe_respond()
return len(data)

def _queue(self, payload: bytes) -> None:
"""Append bytes to the internal receive buffer.

Parameters
----------
payload : bytes
Bytes to enqueue so they can be returned by `read()`.
"""
self._rx.extend(payload)

def _maybe_respond(self) -> None:
"""Inspect written bytes and enqueue protocol responses.

This method implements minimal protocol handling for mock mode.
When known command patterns are detected in the transmit buffer,
corresponding response bytes are queued for later reads.
"""
# Detect “CP.COMMON, <cmd>” patterns
while len(self._tx) >= 2:
if self._tx[0] != CP.COMMON[0]:
# Drop unknown leading bytes
self._tx.pop(0)
continue

cmd = self._tx[1]

# GET_VERSION: ConnectionHandler.get_version reads 9 bytes
# and checks b"PSLab"
if cmd == CP.GET_VERSION[0]:
self._tx = self._tx[2:]
self._queue(self.version.encode("utf-8")[:9].ljust(9, b" "))
continue

# GET_FW_VERSION: reads 3 bytes (major, minor, patch)
if cmd == CP.GET_FW_VERSION[0]:
self._tx = self._tx[2:]
major, minor, patch = self._fw
self._queue(bytes([major, minor, patch]))
continue

# Unknown command under CP.COMMON: drop and stop
break
Comment on lines +122 to +123
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

When an unknown command is encountered under CP.COMMON, the loop breaks instead of continuing to process remaining bytes. This means if multiple commands are written in sequence and one is unknown, subsequent commands won't be processed. Consider using continue after incrementing past the unknown command, or at minimum, consuming the unknown command bytes to prevent them from blocking future command processing.

Suggested change
# Unknown command under CP.COMMON: drop and stop
break
# Unknown command under CP.COMMON: drop it and continue parsing
self._tx = self._tx[2:]
continue

Copilot uses AI. Check for mistakes.
60 changes: 45 additions & 15 deletions pslab/sciencelab.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Iterable, List

import pslab.protocol as CP
from pslab.connection import ConnectionHandler, SerialHandler, autoconnect
from pslab.connection import ConnectionHandler, SerialHandler, MockHandler, autoconnect
from pslab.instrument.logic_analyzer import LogicAnalyzer
from pslab.instrument.multimeter import Multimeter
from pslab.instrument.oscilloscope import Oscilloscope
Expand All @@ -22,27 +22,57 @@
class ScienceLab:
"""Aggregate interface for the PSLab's instruments.

Parameters
----------
device : ConnectionHandler, optional
Connection handler for communicating with the PSLab device. If not
provided, a new one will be created via autoconnect. If both *device*
and *mock* are provided, *device* takes precedence and *mock* is
ignored.
mock : bool, optional
If True, use a MockHandler instead of connecting to physical hardware.
Instruments will not be instantiated in mock mode. The default is
False.

Attributes
----------
logic_analyzer : pslab.LogicAnalyzer
oscilloscope : pslab.Oscilloscope
waveform_generator : pslab.WaveformGenerator
pwm_generator : pslab.PWMGenerator
multimeter : pslab.Multimeter
power_supply : pslab.PowerSupply
logic_analyzer : pslab.LogicAnalyzer or None
oscilloscope : pslab.Oscilloscope or None
waveform_generator : pslab.WaveformGenerator or None
pwm_generator : pslab.PWMGenerator or None
multimeter : pslab.Multimeter or None
power_supply : pslab.PowerSupply or None
i2c : pslab.I2CMaster
nrf : pslab.peripherals.NRF24L01
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The Attributes section of the class docstring lists the instruments but doesn't indicate they can be None in mock mode. Consider updating the docstring to reflect this, for example by adding "These attributes are None when initialized with mock=True." after the attributes list, or by documenting each attribute as "instrument_name : ClassName or None".

Suggested change
nrf : pslab.peripherals.NRF24L01
nrf : pslab.peripherals.NRF24L01
These attributes are set to None when initialized with mock=True.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The docstring for the ScienceLab class should document the new mock parameter. Following the pattern seen in other classes in the codebase (e.g., LogicAnalyzer), a Parameters section should be added documenting both the device and mock parameters. For example:

Parameters

device : ConnectionHandler, optional
Connection handler for communicating with the PSLab device. If not provided, a new one will be created via autoconnect.
mock : bool, optional
If True, use a mock handler instead of connecting to physical hardware. Instruments will not be instantiated in mock mode. The default is False.

Suggested change
nrf : pslab.peripherals.NRF24L01
nrf : pslab.peripherals.NRF24L01
Parameters
----------
device : ConnectionHandler, optional
Connection handler for communicating with the PSLab device. If not
provided, a new one will be created via autoconnect.
mock : bool, optional
If True, use a mock handler instead of connecting to physical
hardware. Instruments will not be instantiated in mock mode. The
default is False.

Copilot uses AI. Check for mistakes.

Notes
-----
Instrument attributes are None when initialized with mock=True.
"""

def __init__(self, device: ConnectionHandler | None = None):
self.device = device if device is not None else autoconnect()
def __init__(self, device: ConnectionHandler | None = None, mock: bool = False):
if device is not None:
self.device = device
elif mock:
self.device = MockHandler()
else:
self.device = autoconnect()
Comment on lines +53 to +59
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

When both device and mock=True are provided, the device parameter silently takes precedence and the mock parameter is ignored. This behavior may be unexpected for users. Consider either raising a ValueError when both are provided, or documenting this precedence in the docstring if it's intentional.

Copilot uses AI. Check for mistakes.
self.firmware = self.device.get_firmware_version()
self.logic_analyzer = LogicAnalyzer(device=self.device)
self.oscilloscope = Oscilloscope(device=self.device)
self.waveform_generator = WaveformGenerator(device=self.device)
self.pwm_generator = PWMGenerator(device=self.device)
self.multimeter = Multimeter(device=self.device)
self.power_supply = PowerSupply(device=self.device)

if not mock: # In mock mode, skip instrument initialization to avoid hardware dependencies
self.logic_analyzer = LogicAnalyzer(device=self.device)
self.oscilloscope = Oscilloscope(device=self.device)
self.waveform_generator = WaveformGenerator(device=self.device)
self.pwm_generator = PWMGenerator(device=self.device)
self.multimeter = Multimeter(device=self.device)
self.power_supply = PowerSupply(device=self.device)
else:
self.logic_analyzer = None
self.oscilloscope = None
self.waveform_generator = None
self.pwm_generator = None
self.multimeter = None
self.power_supply = None
Comment on lines +62 to +75
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The conditional logic here only checks the mock parameter, which can lead to incorrect behavior when both device and mock are provided. For example:

  • ScienceLab(device=MockHandler(), mock=False) would attempt to initialize instruments with a MockHandler
  • ScienceLab(device=real_device, mock=True) would skip instrument initialization despite having a real device

The logic should determine whether to initialize instruments based on the actual type of the device, not just the mock parameter. Consider checking isinstance(self.device, MockHandler) instead of relying solely on the mock parameter value.

Copilot uses AI. Check for mistakes.

@property
def temperature(self):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_sciencelab_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from unittest.mock import patch

from pslab.sciencelab import ScienceLab


def test_sciencelab_mock_does_not_autoconnect():
# If autoconnect is called, the test should fail immediately.
with patch(
"pslab.sciencelab.autoconnect",
side_effect=AssertionError("autoconnect should not be called"),
):
psl = ScienceLab(mock=True)

# It should initialize and provide the expected mock firmware version object.
assert psl.firmware.major == 3
assert psl.firmware.minor == 0
assert psl.firmware.patch == 0

# In mock mode, instruments should not be instantiated (no hardware required).
assert psl.logic_analyzer is None
assert psl.oscilloscope is None
assert psl.waveform_generator is None
assert psl.pwm_generator is None
assert psl.multimeter is None
assert psl.power_supply is None
Loading