Skip to content

Commit 55fdacc

Browse files
committed
Add MockHandler and allow ScienceLab(mock=True)
1 parent 8ae580e commit 55fdacc

4 files changed

Lines changed: 173 additions & 9 deletions

File tree

pslab/connection/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
from .connection import ConnectionHandler
66
from ._serial import SerialHandler
77
from .wlan import WLANHandler
8+
from pslab.connection.mock import MockHandler
9+
10+
__all__ = [
11+
"ConnectionHandler",
12+
"SerialHandler",
13+
"WLANHandler",
14+
"autoconnect",
15+
"MockHandler",
16+
]
817

918

1019
def detect() -> list[ConnectionHandler]:

pslab/connection/mock.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Mock connection handler for PSLab.
2+
3+
This module provides a minimal in-memory `ConnectionHandler` implementation for
4+
use in tests and development without physical PSLab hardware.
5+
"""
6+
7+
from __future__ import annotations
8+
from collections import deque
9+
import pslab.protocol as CP
10+
from pslab.connection.connection import ConnectionHandler
11+
12+
13+
class MockHandler(ConnectionHandler):
14+
"""In-memory mock implementation of `ConnectionHandler`.
15+
16+
The handler queues deterministic responses based on bytes written via
17+
`write()` so higher-level code can be exercised without an actual device.
18+
"""
19+
20+
def __init__(self, version: str = "PSLab V6 ", fw=(3, 0, 0)) -> None:
21+
self._rx = deque() # bytes to be read
22+
self._tx = bytearray() # bytes written by client
23+
self.version = version # convenient attribute for callers
24+
self._fw = fw
25+
self._connected = False
26+
27+
def connect(self) -> None:
28+
"""Mark the handler as connected."""
29+
self._connected = True
30+
# Optional: validate the mock “device” by answering get_version
31+
# self.version = self.get_version()
32+
33+
def disconnect(self) -> None:
34+
"""Mark the handler as disconnected."""
35+
self._connected = False
36+
37+
def read(self, numbytes: int) -> bytes:
38+
"""Read bytes from the internal receive buffer.
39+
40+
Parameters
41+
----------
42+
numbytes : int
43+
Number of bytes to read.
44+
45+
Returns
46+
-------
47+
bytes
48+
Bytes read from the receive buffer (may be shorter if insufficient data
49+
is available).
50+
"""
51+
out = bytearray()
52+
while len(out) < numbytes and self._rx:
53+
out.append(self._rx.popleft())
54+
return bytes(out)
55+
56+
def write(self, data: bytes) -> int:
57+
"""Write bytes to the handler and queue any corresponding responses.
58+
59+
Parameters
60+
----------
61+
data : bytes
62+
Bytes written by the caller.
63+
64+
Returns
65+
-------
66+
int
67+
Number of bytes written.
68+
"""
69+
self._tx.extend(data)
70+
self._maybe_respond()
71+
return len(data)
72+
73+
def _queue(self, payload: bytes) -> None:
74+
"""Append bytes to the internal receive buffer.
75+
76+
Parameters
77+
----------
78+
payload : bytes
79+
Bytes to enqueue so they can be returned by `read()`.
80+
"""
81+
self._rx.extend(payload)
82+
83+
def _maybe_respond(self) -> None:
84+
"""Inspect written bytes and enqueue protocol responses.
85+
86+
This method implements minimal protocol handling for mock mode.
87+
When known command patterns are detected in the transmit buffer,
88+
corresponding response bytes are queued for later reads.
89+
"""
90+
# Detect “CP.COMMON, <cmd>” patterns
91+
while len(self._tx) >= 2:
92+
if self._tx[0] != CP.COMMON:
93+
# Drop unknown leading bytes
94+
self._tx.pop(0)
95+
continue
96+
97+
cmd = self._tx[1]
98+
99+
# GET_VERSION: ConnectionHandler.get_version reads 9 bytes
100+
# and checks b"PSLab"
101+
if cmd == CP.GET_VERSION:
102+
self._tx = self._tx[2:]
103+
self._queue(self.version.encode("utf-8")[:9].ljust(9, b" "))
104+
continue
105+
106+
# GET_FW_VERSION: reads 3 bytes (major, minor, patch)
107+
if cmd == CP.GET_FW_VERSION:
108+
self._tx = self._tx[2:]
109+
major, minor, patch = self._fw
110+
self._queue(bytes([major, minor, patch]))
111+
continue
112+
113+
# Unknown command under CP.COMMON: drop and stop
114+
break

pslab/sciencelab.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Iterable, List
1212

1313
import pslab.protocol as CP
14-
from pslab.connection import ConnectionHandler, SerialHandler, autoconnect
14+
from pslab.connection import ConnectionHandler, SerialHandler, MockHandler, autoconnect
1515
from pslab.instrument.logic_analyzer import LogicAnalyzer
1616
from pslab.instrument.multimeter import Multimeter
1717
from pslab.instrument.oscilloscope import Oscilloscope
@@ -34,15 +34,31 @@ class ScienceLab:
3434
nrf : pslab.peripherals.NRF24L01
3535
"""
3636

37-
def __init__(self, device: ConnectionHandler | None = None):
38-
self.device = device if device is not None else autoconnect()
37+
def __init__(self, device: ConnectionHandler | None = None, mock: bool = False):
38+
if device is not None:
39+
self.device = device
40+
elif mock:
41+
self.device = MockHandler()
42+
else:
43+
self.device = autoconnect()
3944
self.firmware = self.device.get_firmware_version()
40-
self.logic_analyzer = LogicAnalyzer(device=self.device)
41-
self.oscilloscope = Oscilloscope(device=self.device)
42-
self.waveform_generator = WaveformGenerator(device=self.device)
43-
self.pwm_generator = PWMGenerator(device=self.device)
44-
self.multimeter = Multimeter(device=self.device)
45-
self.power_supply = PowerSupply(device=self.device)
45+
46+
# mock mode initializes without hardware; instruments are not initialized.
47+
48+
if not mock:
49+
self.logic_analyzer = LogicAnalyzer(device=self.device)
50+
self.oscilloscope = Oscilloscope(device=self.device)
51+
self.waveform_generator = WaveformGenerator(device=self.device)
52+
self.pwm_generator = PWMGenerator(device=self.device)
53+
self.multimeter = Multimeter(device=self.device)
54+
self.power_supply = PowerSupply(device=self.device)
55+
else:
56+
self.logic_analyzer = None
57+
self.oscilloscope = None
58+
self.waveform_generator = None
59+
self.pwm_generator = None
60+
self.multimeter = None
61+
self.power_supply = None
4662

4763
@property
4864
def temperature(self):

tests/test_sciencelab_mock.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from unittest.mock import patch
2+
3+
from pslab.sciencelab import ScienceLab
4+
5+
6+
def test_sciencelab_mock_does_not_autoconnect():
7+
# If autoconnect is called, the test should fail immediately.
8+
with patch(
9+
"pslab.sciencelab.autoconnect",
10+
side_effect=AssertionError("autoconnect should not be called"),
11+
):
12+
psl = ScienceLab(mock=True)
13+
14+
# It should initialize and provide a firmware version object.
15+
assert psl.firmware.major >= 0
16+
assert psl.firmware.minor >= 0
17+
assert psl.firmware.patch >= 0
18+
19+
# In mock mode, instruments should not be instantiated (no hardware required).
20+
assert psl.logic_analyzer is None
21+
assert psl.oscilloscope is None
22+
assert psl.waveform_generator is None
23+
assert psl.pwm_generator is None
24+
assert psl.multimeter is None
25+
assert psl.power_supply is None

0 commit comments

Comments
 (0)