From bd11b7319af7768a6929ba35d0b5e81b43ee5033 Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 14:59:25 +0100 Subject: [PATCH 1/6] Fix timeout with firmware 3.1.0 --- pslab/bus/spi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pslab/bus/spi.py b/pslab/bus/spi.py index 2e88813f..6e89178e 100644 --- a/pslab/bus/spi.py +++ b/pslab/bus/spi.py @@ -175,7 +175,7 @@ def _set_parameters( self._device.send_byte(CP.SET_SPI_PARAMETERS) # 0Bhgfedcba - > : modebit CKP,: modebit CKE, :primary prescaler, # :secondary prescaler - self._device.send_byte( + self._device.send_int( secondary_prescaler | (primary_prescaler << 3) | (CKE << 5) From 2ae3f0968fbaac9b99d7fc037fd82f16660cd6e1 Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 15:14:02 +0100 Subject: [PATCH 2/6] Refactor SerialHandler --- pslab/bus/busio.py | 13 +- pslab/bus/i2c.py | 10 +- pslab/bus/spi.py | 10 +- pslab/bus/uart.py | 8 +- pslab/connection/__init__.py | 63 ++++++++ pslab/connection/_serial.py | 159 ++++++++++++++++++++ pslab/connection/connection.py | 199 +++++++++++++++++++++++++ pslab/instrument/buffer.py | 87 +++++++++++ pslab/instrument/logic_analyzer.py | 9 +- pslab/instrument/multimeter.py | 4 +- pslab/instrument/oscilloscope.py | 7 +- pslab/instrument/power_supply.py | 6 +- pslab/instrument/waveform_generator.py | 10 +- pslab/sciencelab.py | 141 +++++++++--------- tests/conftest.py | 6 +- tests/test_i2c.py | 2 +- tests/test_motor.py | 2 +- tests/test_multimeter.py | 2 +- tests/test_power_supply.py | 2 +- tests/test_serial_handler.py | 24 +-- tests/test_spi.py | 2 +- tests/test_uart.py | 2 +- tests/test_waveform_generator.py | 2 +- 23 files changed, 639 insertions(+), 131 deletions(-) create mode 100644 pslab/connection/__init__.py create mode 100644 pslab/connection/_serial.py create mode 100644 pslab/connection/connection.py create mode 100644 pslab/instrument/buffer.py diff --git a/pslab/bus/busio.py b/pslab/bus/busio.py index 2b75b0b7..97771dca 100644 --- a/pslab/bus/busio.py +++ b/pslab/bus/busio.py @@ -36,7 +36,7 @@ from pslab.bus.i2c import _I2CPrimitive from pslab.bus.spi import _SPIPrimitive from pslab.bus.uart import _UARTPrimitive -from pslab.serial_handler import SerialHandler +from pslab.connection import ConnectionHandler __all__ = ( "I2C", @@ -59,7 +59,12 @@ class I2C(_I2CPrimitive): Frequency of SCL in Hz. """ - def __init__(self, device: SerialHandler = None, *, frequency: int = 125e3): + def __init__( + self, + device: ConnectionHandler | None = None, + *, + frequency: int = 125e3, + ): # 125 kHz is as low as the PSLab can go. super().__init__(device) self._init() @@ -199,7 +204,7 @@ class SPI(_SPIPrimitive): created. """ - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): super().__init__(device) ppre, spre = self._get_prescaler(25e4) self._set_parameters(ppre, spre, 1, 0, 1) @@ -412,7 +417,7 @@ class UART(_UARTPrimitive): def __init__( self, - device: SerialHandler = None, + device: ConnectionHandler | None = None, *, baudrate: int = 9600, bits: int = 8, diff --git a/pslab/bus/i2c.py b/pslab/bus/i2c.py index 4704fec2..dd9b792b 100644 --- a/pslab/bus/i2c.py +++ b/pslab/bus/i2c.py @@ -22,7 +22,7 @@ from typing import List import pslab.protocol as CP -from pslab.serial_handler import SerialHandler +from pslab.connection import ConnectionHandler, autoconnect from pslab.external.sensorlist import sensors __all__ = ( @@ -54,8 +54,8 @@ class _I2CPrimitive: _READ = 1 _WRITE = 0 - def __init__(self, device: SerialHandler = None): - self._device = device if device is not None else SerialHandler() + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() self._running = False self._mode = None @@ -447,7 +447,7 @@ class I2CMaster(_I2CPrimitive): created. """ - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): super().__init__(device) self._init() self.configure(125e3) # 125 kHz is as low as the PSLab can go. @@ -506,7 +506,7 @@ class I2CSlave(_I2CPrimitive): def __init__( self, address: int, - device: SerialHandler = None, + device: ConnectionHandler | None = None, ): super().__init__(device) self.address = address diff --git a/pslab/bus/spi.py b/pslab/bus/spi.py index 6e89178e..ad885ceb 100644 --- a/pslab/bus/spi.py +++ b/pslab/bus/spi.py @@ -23,7 +23,7 @@ import pslab.protocol as CP from pslab.bus import classmethod_ -from pslab.serial_handler import SerialHandler +from pslab.connection import ConnectionHandler, autoconnect __all__ = ( "SPIMaster", @@ -67,8 +67,8 @@ class _SPIPrimitive: _clock_edge = _CKE # Clock Edge Select bit (inverse of Clock Phase bit). _smp = _SMP # Data Input Sample Phase bit. - def __init__(self, device: SerialHandler = None): - self._device = device if device is not None else SerialHandler() + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() @classmethod_ @property @@ -419,7 +419,7 @@ class SPIMaster(_SPIPrimitive): created. """ - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): super().__init__(device) # Reset config self.set_parameters() @@ -492,7 +492,7 @@ class SPISlave(_SPIPrimitive): created. """ - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): super().__init__(device) def transfer8(self, data: int) -> int: diff --git a/pslab/bus/uart.py b/pslab/bus/uart.py index 340ff4ca..a3bbf527 100644 --- a/pslab/bus/uart.py +++ b/pslab/bus/uart.py @@ -17,7 +17,7 @@ import pslab.protocol as CP from pslab.bus import classmethod_ -from pslab.serial_handler import SerialHandler +from pslab.connection import ConnectionHandler, autoconnect __all__ = "UART" _BRGVAL = 0x22 # BaudRate = 460800. @@ -41,8 +41,8 @@ class _UARTPrimitive: _brgval = _BRGVAL _mode = _MODE - def __init__(self, device: SerialHandler = None): - self._device = device if device is not None else SerialHandler() + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() @classmethod_ @property @@ -227,7 +227,7 @@ class UART(_UARTPrimitive): Serial connection to PSLab device. If not provided, a new one will be created. """ - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): super().__init__(device) # Reset baudrate and mode self.configure(self._get_uart_baudrate(_BRGVAL)) diff --git a/pslab/connection/__init__.py b/pslab/connection/__init__.py new file mode 100644 index 00000000..0b5abcbe --- /dev/null +++ b/pslab/connection/__init__.py @@ -0,0 +1,63 @@ +"""Interfaces for communicating with PSLab devices.""" + +from serial.tools import list_ports + +from .connection import ConnectionHandler +from ._serial import SerialHandler + + +def detect() -> list[ConnectionHandler]: + """Detect PSLab devices. + + Returns + ------- + devices : list[ConnectionHandler] + Handlers for all detected PSLabs. The returned handlers are disconnected; call + .connect() before use. + """ + regex = [] + + for vid, pid in zip(SerialHandler._USB_VID, SerialHandler._USB_PID): + regex.append(f"{vid:04x}:{pid:04x}") + + regex = "(" + "|".join(regex) + ")" + port_info_generator = list_ports.grep(regex) + pslab_devices = [] + + for port_info in port_info_generator: + device = SerialHandler(port=port_info.device, baudrate=1000000, timeout=1) + + try: + device.connect() + except Exception: + pass # nosec + else: + pslab_devices.append(device) + finally: + device.disconnect() + + return pslab_devices + + +def autoconnect() -> ConnectionHandler: + """Automatically connect when exactly one device is present. + + Returns + ------- + device : ConnectionHandler + A handler connected to the detected PSLab device. The handler is connected; it + is not necessary to call .connect before use(). + """ + devices = detect() + + if not devices: + msg = "device not found" + raise ConnectionError(msg) + + if len(devices) > 1: + msg = f"autoconnect failed, multiple devices detected: {devices}" + raise ConnectionError(msg) + + device = devices[0] + device.connect() + return device diff --git a/pslab/connection/_serial.py b/pslab/connection/_serial.py new file mode 100644 index 00000000..691e8556 --- /dev/null +++ b/pslab/connection/_serial.py @@ -0,0 +1,159 @@ +"""Serial interface for communicating with PSLab devices.""" + +import os +import platform + +import serial + +import pslab +from pslab.connection.connection import ConnectionHandler + + +def _check_serial_access_permission(): + """Check that we have permission to use the tty on Linux.""" + if platform.system() == "Linux": + import grp + + if os.geteuid() == 0: # Running as root? + return + + for group in os.getgroups(): + if grp.getgrgid(group).gr_name in ( + "dialout", + "uucp", + ): + return + + udev_paths = [ + "/run/udev/rules.d/", + "/etc/udev/rules.d/", + "/lib/udev/rules.d/", + ] + for p in udev_paths: + udev_rules = os.path.join(p, "99-pslab.rules") + if os.path.isfile(udev_rules): + return + else: + raise PermissionError( + "The current user does not have permission to access " + "the PSLab device. To solve this, either:" + "\n\n" + "1. Add the user to the 'dialout' (on Debian-based " + "systems) or 'uucp' (on Arch-based systems) group." + "\n" + "2. Install a udev rule to allow any user access to the " + "device by running 'pslab install' as root, or by " + "manually copying " + f"{pslab.__path__[0]}/99-pslab.rules into {udev_paths[1]}." + "\n\n" + "You may also need to reboot the system for the " + "permission changes to take effect." + ) + + +class SerialHandler(ConnectionHandler): + """Interface for controlling a PSLab over a serial port. + + Parameters + ---------- + port : str + baudrate : int, default 1 MBd + timeout : float, default 1 s + """ + + # V5 V6 + _USB_VID = [0x04D8, 0x10C4] + _USB_PID = [0x00DF, 0xEA60] + + def __init__( + self, + port: str, + baudrate: int = 1000000, + timeout: float = 1.0, + ): + self._port = port + self._ser = serial.Serial( + baudrate=baudrate, + timeout=timeout, + write_timeout=timeout, + ) + _check_serial_access_permission() + + @property + def port(self) -> str: + """Serial port.""" + return self._port + + @property + def baudrate(self) -> int: + """Symbol rate.""" + return self._ser.baudrate + + @baudrate.setter + def baudrate(self, value: int) -> None: + self._ser.baudrate = value + + @property + def timeout(self) -> float: + """Timeout in seconds.""" + return self._ser.timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._ser.timeout = value + self._ser.write_timeout = value + + def connect(self) -> None: + """Connect to PSLab.""" + self._ser.port = self.port + self._ser.open() + + try: + self.get_version() + except Exception: + self._ser.close() + raise + + def disconnect(self): + """Disconnect from PSLab.""" + self._ser.close() + + def read(self, number_of_bytes: int) -> bytes: + """Read bytes from serial port. + + Parameters + ---------- + number_of_bytes : int + Number of bytes to read from the serial port. + + Returns + ------- + bytes + Bytes read from the serial port. + """ + return self._ser.read(number_of_bytes) + + def write(self, data: bytes) -> int: + """Write bytes to serial port. + + Parameters + ---------- + data : int + Bytes to write to the serial port. + + Returns + ------- + int + Number of bytes written. + """ + return self._ser.write(data) + + def __repr__(self) -> str: # noqa + return ( + f"{self.__class__.__name__}" + "[" + f"{self.port}, " + f"{self.baudrate} baud, " + f"timeout {self.timeout} s" + "]" + ) diff --git a/pslab/connection/connection.py b/pslab/connection/connection.py new file mode 100644 index 00000000..d87fd187 --- /dev/null +++ b/pslab/connection/connection.py @@ -0,0 +1,199 @@ +"""Interface objects common to all types of connections.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import pslab.protocol as CP + + +@dataclass(frozen=True) +class FirmwareVersion: + """Version of pslab-firmware running on connected device. + + Uses semantic versioning conventions. + + Attributes + ---------- + major : int + Major version. Incremented when backward imcompatible changes are made. + minor : int + Minor version. Incremented when new functionality is added, or existing + functionality is changed in a backward compatible manner. + patch : int + Patch version. Incremented when bug fixes are made with do not change the + PSLab's documented behavior. + """ + + major: int + minor: int + patch: int + + +class ConnectionHandler(ABC): + """Abstract base class for PSLab control interfaces.""" + + @abstractmethod + def connect(self) -> None: + """Connect to PSLab.""" + ... + + @abstractmethod + def disconnect(self) -> None: + """Disconnect PSLab.""" + ... + + @abstractmethod + def read(self, numbytes: int) -> bytes: + """Read data from PSLab. + + Parameters + ---------- + numbytes : int + + Returns + ------- + data : bytes + """ + ... + + @abstractmethod + def write(self, data: bytes) -> int: + """Write data to PSLab. + + Parameters + ---------- + data : bytes + + Returns + ------- + numbytes : int + """ + ... + + def get_byte(self) -> int: + """Read a single one-byte of integer value. + + Returns + ------- + int + """ + return int.from_bytes(self.read(1), byteorder="little") + + def get_int(self) -> int: + """Read a single two-byte integer value. + + Returns + ------- + int + """ + return int.from_bytes(self.read(2), byteorder="little") + + def get_long(self) -> int: + """Read a single four-byte integer value. + + Returns + ------- + int + """ + return int.from_bytes(self.read(4), byteorder="little") + + def send_byte(self, data: int | bytes) -> None: + """Write a single one-byte integer value. + + Parameters + ---------- + data : int + """ + if isinstance(data, int): + data = data.to_bytes(length=1, byteorder="little") + self.write(data) + + def send_int(self, data: int | bytes) -> None: + """Write a single two-byte integer value. + + Parameters + ---------- + data : int | bytes + """ + if isinstance(data, int): + data = data.to_bytes(length=2, byteorder="little") + self.write(data) + + def send_long(self, data: int | bytes) -> None: + """Write a single four-byte integer value. + + Parameters + ---------- + data : int | bytes + """ + if isinstance(data, int): + data = data.to_bytes(length=4, byteorder="little") + self.write(data) + + def get_ack(self) -> int: + """Get response code from PSLab. + + Returns + ------- + int + Response code. Meanings: + 0x01 ACK + 0x10 I2C ACK + 0x20 I2C bus collision + 0x10 Radio max retransmits + 0x20 Radio not present + 0x40 Radio reply timout + """ + response = self.read(1) + + if not response: + raise TimeoutError + + ack = CP.Byte.unpack(response)[0] + + if not (ack & 0x01): + raise RuntimeError("Received non ACK byte while waiting for ACK.") + + return ack + + def get_version(self) -> str: + """Query PSLab for its version and return it as a decoded string. + + Returns + ------- + str + Version string. + """ + self.send_byte(CP.COMMON) + self.send_byte(CP.GET_VERSION) + version_length = 9 + version = self.read(version_length) + + try: + if b"PSLab" not in version: + msg = f"got unexpected hardware version: {version}" + raise ConnectionError(msg) + except Exception as exc: + msg = "device not found" + raise ConnectionError(msg) from exc + + return version.decode("utf-8") + + def get_firmware_version(self) -> FirmwareVersion: + """Get firmware version. + + Returns + ------- + tuple[int, int, int] + major, minor, patch. + + """ + self.send_byte(CP.COMMON) + self.send_byte(CP.GET_FW_VERSION) + + # Firmware version query was added in firmware version 3.0.0. + major = self.get_byte() + minor = self.get_byte() + patch = self.get_byte() + + return FirmwareVersion(major, minor, patch) diff --git a/pslab/instrument/buffer.py b/pslab/instrument/buffer.py new file mode 100644 index 00000000..a2a5cdbb --- /dev/null +++ b/pslab/instrument/buffer.py @@ -0,0 +1,87 @@ +"""The PSLab has a sample buffer where collected data is stored temporarily.""" + +import pslab.protocol as CP + + +class ADCBufferMixin: + """Mixin for classes that need to read or write to the ADC buffer.""" + + def fetch_buffer(self, samples: int, starting_position: int = 0): + """Fetch a section of the ADC buffer. + + Parameters + ---------- + samples : int + Number of samples to fetch. + starting_position : int, optional + Location in the ADC buffer to start from. By default samples will + be fetched from the beginning of the buffer. + + Returns + ------- + received : list of int + List of received samples. + """ + received = [] + buf_size = 128 + remaining = samples + idx = starting_position + + while remaining > 0: + self._device.send_byte(CP.COMMON) + self._device.send_byte(CP.RETRIEVE_BUFFER) + self._device.send_int(idx) + samps = min(remaining, buf_size) + self._device.send_int(samps) + received += [self._device.get_int() for _ in range(samps)] + self._device.get_ack() + remaining -= samps + idx += samps + + return received + + def clear_buffer(self, samples: int, starting_position: int = 0): + """Clear a section of the ADC buffer. + + Parameters + ---------- + samples : int + Number of samples to clear from the buffer. + starting_position : int, optional + Location in the ADC buffer to start from. By default samples will + be cleared from the beginning of the buffer. + """ + self._device.send_byte(CP.COMMON) + self._device.send_byte(CP.CLEAR_BUFFER) + self._device.send_int(starting_position) + self._device.send_int(samples) + self._device.get_ack() + + def fill_buffer(self, data: list[int], starting_position: int = 0): + """Fill a section of the ADC buffer with data. + + Parameters + ---------- + data : list of int + Values to write to the ADC buffer. + starting_position : int, optional + Location in the ADC buffer to start from. By default writing will + start at the beginning of the buffer. + """ + buf_size = 128 + idx = starting_position + remaining = len(data) + + while remaining > 0: + self._device.send_byte(CP.COMMON) + self._device.send_byte(CP.FILL_BUFFER) + self._device.send_int(idx) + samps = min(remaining, buf_size) + self._device.send_int(samps) + + for value in data[idx : idx + samps]: + self._device.send_int(value) + + self._device.get_ack() + idx += samps + remaining -= samps diff --git a/pslab/instrument/logic_analyzer.py b/pslab/instrument/logic_analyzer.py index c8ff536b..8888034f 100644 --- a/pslab/instrument/logic_analyzer.py +++ b/pslab/instrument/logic_analyzer.py @@ -14,8 +14,9 @@ import numpy as np import pslab.protocol as CP +from pslab.connection import ConnectionHandler, autoconnect +from pslab.instrument.buffer import ADCBufferMixin from pslab.instrument.digital import DigitalInput, DIGITAL_INPUTS, MODES -from pslab.serial_handler import ADCBufferMixin, SerialHandler class LogicAnalyzer(ADCBufferMixin): @@ -46,8 +47,8 @@ class LogicAnalyzer(ADCBufferMixin): # delay between channels. _CAPTURE_DELAY = 2 - def __init__(self, device: SerialHandler = None): - self._device = SerialHandler() if device is None else device + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() self._channels = {d: DigitalInput(d) for d in DIGITAL_INPUTS} self.trigger_channel = "LA1" self._trigger_channel = self._channels["LA1"] @@ -108,7 +109,7 @@ def _measure_frequency_firmware( self._device.send_byte(CP.GET_FREQUENCY) self._device.send_int(int(timeout * 64e6) >> 16) self._device.send_byte(self._channels[channel].number) - self._device.wait_for_data(timeout) + time.sleep(timeout) error = self._device.get_byte() t = [self._device.get_long() for a in range(2)] diff --git a/pslab/instrument/multimeter.py b/pslab/instrument/multimeter.py index 53b0d07c..db7f6d53 100644 --- a/pslab/instrument/multimeter.py +++ b/pslab/instrument/multimeter.py @@ -7,9 +7,9 @@ from scipy.optimize import curve_fit import pslab.protocol as CP +from pslab.connection import ConnectionHandler from pslab.instrument.analog import GAIN_VALUES, INPUT_RANGES from pslab.instrument.oscilloscope import Oscilloscope -from pslab.serial_handler import SerialHandler _MICROSECONDS = 1e-6 @@ -30,7 +30,7 @@ class Multimeter(Oscilloscope): _CAPACITOR_CHARGED_VOLTAGE = 0.9 * max(INPUT_RANGES["CAP"]) _CAPACITOR_DISCHARGED_VOLTAGE = 0.01 * max(INPUT_RANGES["CAP"]) - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): self._stray_capacitance = 46e-12 super().__init__(device) diff --git a/pslab/instrument/oscilloscope.py b/pslab/instrument/oscilloscope.py index e2733d81..b8d3e30c 100644 --- a/pslab/instrument/oscilloscope.py +++ b/pslab/instrument/oscilloscope.py @@ -14,8 +14,9 @@ import pslab.protocol as CP from pslab.bus.spi import SPIMaster +from pslab.connection import ConnectionHandler, autoconnect from pslab.instrument.analog import ANALOG_CHANNELS, AnalogInput, GAIN_VALUES -from pslab.serial_handler import ADCBufferMixin, SerialHandler +from pslab.instrument.buffer import ADCBufferMixin class Oscilloscope(ADCBufferMixin): @@ -30,8 +31,8 @@ class Oscilloscope(ADCBufferMixin): _CH234 = ["CH2", "CH3", "MIC"] - def __init__(self, device: SerialHandler = None): - self._device = SerialHandler() if device is None else device + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() self._channels = {a: AnalogInput(a) for a in ANALOG_CHANNELS} self._channel_one_map = "CH1" self._trigger_voltage = None diff --git a/pslab/instrument/power_supply.py b/pslab/instrument/power_supply.py index abd0ae79..7d31885e 100644 --- a/pslab/instrument/power_supply.py +++ b/pslab/instrument/power_supply.py @@ -14,7 +14,7 @@ """ import pslab.protocol as CP -from pslab.serial_handler import SerialHandler +from pslab.connection import ConnectionHandler, autoconnect class PowerSupply: @@ -41,8 +41,8 @@ class PowerSupply: _PCS_CH = 0 _PCS_RANGE = (3.3e-3, 0) - def __init__(self, device: SerialHandler = None): - self._device = device if device is not None else SerialHandler() + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() self._pv1 = None self._pv2 = None self._pv3 = None diff --git a/pslab/instrument/waveform_generator.py b/pslab/instrument/waveform_generator.py index 8c33efbb..07c35238 100644 --- a/pslab/instrument/waveform_generator.py +++ b/pslab/instrument/waveform_generator.py @@ -11,9 +11,9 @@ import numpy as np import pslab.protocol as CP +from pslab.connection import ConnectionHandler, autoconnect from pslab.instrument.analog import AnalogOutput from pslab.instrument.digital import DigitalOutput, DIGITAL_OUTPUTS -from pslab.serial_handler import SerialHandler logger = logging.getLogger(__name__) @@ -117,9 +117,9 @@ class WaveformGenerator: _HIGH_FREQUENCY_WARNING = 5e3 _HIGHRES_FREQUENCY_LIMIT = 1100 - def __init__(self, device: SerialHandler = None): + def __init__(self, device: ConnectionHandler | None = None): self._channels = {n: AnalogOutput(n) for n in ("SI1", "SI2")} - self._device = device if device is not None else SerialHandler() + self._device = device if device is not None else autoconnect() def generate( self, @@ -342,8 +342,8 @@ class PWMGenerator: _LOW_FREQUENCY_LIMIT = 4 _HIGH_FREQUENCY_LIMIT = 1e7 - def __init__(self, device: SerialHandler = None): - self._device = device if device is not None else SerialHandler() + def __init__(self, device: ConnectionHandler | None = None): + self._device = device if device is not None else autoconnect() self._channels = {n: DigitalOutput(n) for n in DIGITAL_OUTPUTS} self._frequency = 0 self._reference_prescaler = 0 diff --git a/pslab/sciencelab.py b/pslab/sciencelab.py index 6f105af0..bf4b16ff 100644 --- a/pslab/sciencelab.py +++ b/pslab/sciencelab.py @@ -5,19 +5,21 @@ collection. """ +from __future__ import annotations + import time from typing import Iterable, List import pslab.protocol as CP +from pslab.connection import ConnectionHandler, SerialHandler, autoconnect from pslab.instrument.logic_analyzer import LogicAnalyzer from pslab.instrument.multimeter import Multimeter from pslab.instrument.oscilloscope import Oscilloscope from pslab.instrument.power_supply import PowerSupply from pslab.instrument.waveform_generator import PWMGenerator, WaveformGenerator -from pslab.serial_handler import SerialHandler -class ScienceLab(SerialHandler): +class ScienceLab: """Aggregate interface for the PSLab's instruments. Attributes @@ -32,19 +34,14 @@ class ScienceLab(SerialHandler): nrf : pslab.peripherals.NRF24L01 """ - def __init__( - self, - port: str = None, - baudrate: int = 1000000, - timeout: float = 1.0, - ): - super().__init__(port, baudrate, timeout) - self.logic_analyzer = LogicAnalyzer(device=self) - self.oscilloscope = Oscilloscope(device=self) - self.waveform_generator = WaveformGenerator(device=self) - self.pwm_generator = PWMGenerator(device=self) - self.multimeter = Multimeter(device=self) - self.power_supply = PowerSupply(device=self) + def __init__(self, device: ConnectionHandler | None = None): + self.device = device if device is not None else autoconnect() + 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) @property def temperature(self): @@ -91,43 +88,47 @@ def _get_ctmu_voltage(self, channel: int, current_range: int, tgen: bool = True) ------- voltage : float """ - self.send_byte(CP.COMMON) - self.send_byte(CP.GET_CTMU_VOLTAGE) - self.send_byte((channel) | (current_range << 5) | (tgen << 7)) + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.GET_CTMU_VOLTAGE) + self.device.send_byte((channel) | (current_range << 5) | (tgen << 7)) raw_voltage = self.get_int() / 16 # 16*voltage across the current source - self.get_ack() + self.device.get_ack() vmax = 3.3 resolution = 12 voltage = vmax * raw_voltage / (2**resolution - 1) return voltage def _start_ctmu(self, current_range: int, trim: int, tgen: int = 1): - self.send_byte(CP.COMMON) - self.send_byte(CP.START_CTMU) - self.send_byte((current_range) | (tgen << 7)) - self.send_byte(trim) - self.get_ack() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.START_CTMU) + self.device.send_byte((current_range) | (tgen << 7)) + self.device.send_byte(trim) + self.device.get_ack() def _stop_ctmu(self): - self.send_byte(CP.COMMON) - self.send_byte(CP.STOP_CTMU) - self.get_ack() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.STOP_CTMU) + self.device.get_ack() def reset(self): """Reset the device.""" - self.send_byte(CP.COMMON) - self.send_byte(CP.RESTORE_STANDALONE) + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.RESTORE_STANDALONE) def enter_bootloader(self): """Reboot and stay in bootloader mode.""" + if not isinstance(self.device, SerialHandler): + msg = "cannot enter bootloader over wireless" + raise RuntimeError(msg) + self.reset() - self.interface.baudrate = 460800 + self.device.interface.baudrate = 460800 # The PSLab's RGB LED flashes some colors on boot. boot_lightshow_time = 0.6 # Wait before sending magic number to make sure UART is initialized. time.sleep(boot_lightshow_time / 2) # PIC24 UART RX buffer is four bytes deep; no need to time it perfectly. - self.write(CP.Integer.pack(0xDECAFBAD)) + self.device.write(CP.Integer.pack(0xDECAFBAD)) # Wait until lightshow is done to prevent accidentally overwriting magic number. time.sleep(boot_lightshow_time) @@ -162,7 +163,7 @@ def rgb_led(self, colors: List, output: str = "RGB", order: str = "GRB"): >>> psl.rgb_led([[10,0,0],[0,10,10],[10,0,10]], output="SQ1", order="RGB") """ - if "6" in self.version: + if "6" in self.device.version: pins = {"ONBOARD": 0, "SQ1": 1, "SQ2": 2, "SQ3": 3, "SQ4": 4} else: pins = {"RGB": CP.SET_RGB1, "PGC": CP.SET_RGB2, "SQ1": CP.SET_RGB3} @@ -188,24 +189,24 @@ def rgb_led(self, colors: List, output: str = "RGB", order: str = "GRB"): f"Invalid order: {order}. order must contain 'R', 'G', and 'B'." ) - self.send_byte(CP.COMMON) + self.device.send_byte(CP.COMMON) - if "6" in self.version: - self.send_byte(CP.SET_RGB_COMMON) + if "6" in self.device.version: + self.device.send_byte(CP.SET_RGB_COMMON) else: - self.send_byte(pin) + self.device.send_byte(pin) - self.send_byte(len(colors) * 3) + self.device.send_byte(len(colors) * 3) for color in colors: - self.send_byte(color[order.index("R")]) - self.send_byte(color[order.index("G")]) - self.send_byte(color[order.index("B")]) + self.device.send_byte(color[order.index("R")]) + self.device.send_byte(color[order.index("G")]) + self.device.send_byte(color[order.index("B")]) - if "6" in self.version: - self.send_byte(pin) + if "6" in self.device.version: + self.device.send_byte(pin) - self.get_ack() + self.device.get_ack() def _read_program_address(self, address: int): """Return the value stored at the specified address in program memory. @@ -220,12 +221,12 @@ def _read_program_address(self, address: int): data : int 16-bit wide value read from program memory. """ - self.send_byte(CP.COMMON) - self.send_byte(CP.READ_PROGRAM_ADDRESS) - self.send_int(address & 0xFFFF) - self.send_int((address >> 16) & 0xFFFF) - data = self.get_int() - self.get_ack() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.READ_PROGRAM_ADDRESS) + self.device.send_int(address & 0xFFFF) + self.device.send_int((address >> 16) & 0xFFFF) + data = self.device.get_int() + self.device.get_ack() return data def _device_id(self): @@ -249,11 +250,11 @@ def _read_data_address(self, address: int): data : int 16-bit wide value read from RAM. """ - self.send_byte(CP.COMMON) - self.send_byte(CP.READ_DATA_ADDRESS) - self.send_int(address & 0xFFFF) - data = self.get_int() - self.get_ack() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.READ_DATA_ADDRESS) + self.device.send_int(address & 0xFFFF) + data = self.device.get_int() + self.device.get_ack() return data def _write_data_address(self, address: int, value: int): @@ -266,11 +267,11 @@ def _write_data_address(self, address: int, value: int): value : int Value to write to RAM. """ - self.send_byte(CP.COMMON) - self.send_byte(CP.WRITE_DATA_ADDRESS) - self.send_int(address & 0xFFFF) - self.send_int(value) - self.get_ack() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.WRITE_DATA_ADDRESS) + self.device.send_int(address & 0xFFFF) + self.device.send_int(value) + self.device.get_ack() def enable_uart_passthrough(self, baudrate: int): """Relay all data received by the device to TXD/RXD. @@ -289,17 +290,17 @@ def enable_uart_passthrough(self, baudrate: int): self._uart_passthrough(baudrate) def _uart_passthrough(self, baudrate: int) -> None: - self.send_byte(CP.PASSTHROUGHS) - self.send_byte(CP.PASS_UART) - self.send_int(self._get_brgval(baudrate)) - self.interface.baudrate = baudrate + self.device.send_byte(CP.PASSTHROUGHS) + self.device.send_byte(CP.PASS_UART) + self.device.send_int(self._get_brgval(baudrate)) + self.device.interface.baudrate = baudrate def _uart_passthrough_legacy(self, baudrate: int) -> None: - self.send_byte(CP.PASSTHROUGHS_LEGACY) - self.send_byte(CP.PASS_UART) + self.device.send_byte(CP.PASSTHROUGHS_LEGACY) + self.device.send_byte(CP.PASS_UART) disable_watchdog = 1 - self.send_byte(disable_watchdog) - self.send_int(self._get_brgval(baudrate)) + self.device.send_byte(disable_watchdog) + self.device.send_int(self._get_brgval(baudrate)) @staticmethod def _get_brgval(baudrate: int) -> int: @@ -313,8 +314,8 @@ def read_log(self): log : bytes Bytes read from the hardware debug log. """ - self.send_byte(CP.COMMON) - self.send_byte(CP.READ_LOG) - log = self.interface.readline().strip() + self.device.send_byte(CP.COMMON) + self.device.send_byte(CP.READ_LOG) + log = self.device.interface.readline().strip() self.get_ack() return log diff --git a/tests/conftest.py b/tests/conftest.py index 9eb6aacf..db604d15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,12 @@ import pytest -from pslab import serial_handler +from pslab.connection import SerialHandler @pytest.fixture def handler(): """Return a SerialHandler instance.""" - return serial_handler.SerialHandler() + sh = SerialHandler() + sh.connect() + return sh diff --git a/tests/test_i2c.py b/tests/test_i2c.py index 6b9eed5d..9bb899f2 100644 --- a/tests/test_i2c.py +++ b/tests/test_i2c.py @@ -10,7 +10,7 @@ from pslab.bus.i2c import I2CMaster, I2CSlave from pslab.instrument.logic_analyzer import LogicAnalyzer -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler ADDRESS = 0x52 # Not a real device. REGISTER_ADDRESS = 0x06 diff --git a/tests/test_motor.py b/tests/test_motor.py index ed2af3c2..4330e87e 100644 --- a/tests/test_motor.py +++ b/tests/test_motor.py @@ -8,7 +8,7 @@ from pslab.external.motor import Servo from pslab.instrument.logic_analyzer import LogicAnalyzer from pslab.instrument.waveform_generator import PWMGenerator -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler RELTOL = 0.01 diff --git a/tests/test_multimeter.py b/tests/test_multimeter.py index f0a7a5d4..d83581f8 100644 --- a/tests/test_multimeter.py +++ b/tests/test_multimeter.py @@ -10,7 +10,7 @@ from pslab.instrument.multimeter import Multimeter from pslab.instrument.power_supply import PowerSupply -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler RELTOL = 0.05 diff --git a/tests/test_power_supply.py b/tests/test_power_supply.py index 38eaf4a6..ccb22120 100644 --- a/tests/test_power_supply.py +++ b/tests/test_power_supply.py @@ -13,7 +13,7 @@ import pytest import numpy as np -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler from pslab.instrument.multimeter import Multimeter from pslab.instrument.power_supply import PowerSupply diff --git a/tests/test_serial_handler.py b/tests/test_serial_handler.py index e72e2c5c..dcbf1a1a 100644 --- a/tests/test_serial_handler.py +++ b/tests/test_serial_handler.py @@ -3,7 +3,7 @@ from serial.tools.list_ports_common import ListPortInfo import pslab.protocol as CP -from pslab.serial_handler import detect, SerialHandler +from pslab.connection import detect, SerialHandler VERSION = "PSLab vMOCK\n" PORT = "mock_port" @@ -22,7 +22,7 @@ def mock_ListPortInfo(found=True, multiple=False): @pytest.fixture def mock_serial(mocker): - serial_patch = mocker.patch("pslab.serial_handler.serial.Serial") + serial_patch = mocker.patch("pslab.connection._serial.serial.Serial") serial_patch().readline.return_value = VERSION.encode() serial_patch().is_open = False return serial_patch @@ -30,14 +30,14 @@ def mock_serial(mocker): @pytest.fixture def mock_handler(mocker, mock_serial, mock_list_ports): - mocker.patch("pslab.serial_handler.SerialHandler.check_serial_access_permission") + mocker.patch("pslab.connection._serial._check_serial_access_permission") mock_list_ports.grep.return_value = mock_ListPortInfo() return SerialHandler() @pytest.fixture def mock_list_ports(mocker): - return mocker.patch("pslab.serial_handler.list_ports") + return mocker.patch("pslab.connection.list_ports") def test_detect(mocker, mock_serial, mock_list_ports): @@ -47,21 +47,21 @@ def test_detect(mocker, mock_serial, mock_list_ports): def test_connect_scan_port(mocker, mock_serial, mock_list_ports): mock_list_ports.grep.return_value = mock_ListPortInfo() - mocker.patch("pslab.serial_handler.SerialHandler.check_serial_access_permission") + mocker.patch("pslab.connection._serial._check_serial_access_permission") SerialHandler() mock_serial().open.assert_called() def test_connect_scan_failure(mocker, mock_serial, mock_list_ports): mock_list_ports.grep.return_value = mock_ListPortInfo(found=False) - mocker.patch("pslab.serial_handler.SerialHandler.check_serial_access_permission") + mocker.patch("pslab.connection._serial._check_serial_access_permission") with pytest.raises(SerialException): SerialHandler() def test_connect_multiple_connected(mocker, mock_serial, mock_list_ports): mock_list_ports.grep.return_value = mock_ListPortInfo(multiple=True) - mocker.patch("pslab.serial_handler.SerialHandler.check_serial_access_permission") + mocker.patch("pslab.connection._serial._check_serial_access_permission") with pytest.raises(RuntimeError): SerialHandler() @@ -118,16 +118,6 @@ def test_receive_failure(mock_serial, mock_handler): mock_handler.get_byte() -def test_wait_for_data(mock_serial, mock_handler): - mock_serial().in_waiting = True - assert mock_handler.wait_for_data() - - -def test_wait_for_data_timeout(mock_serial, mock_handler): - mock_serial().in_waiting = False - assert not mock_handler.wait_for_data() - - def test_get_integer_unsupported_size(mock_serial, mock_handler): with pytest.raises(ValueError): mock_handler._get_integer_type(size=3) diff --git a/tests/test_spi.py b/tests/test_spi.py index a73d7928..ecd73d92 100644 --- a/tests/test_spi.py +++ b/tests/test_spi.py @@ -15,7 +15,7 @@ from pslab.bus.spi import SPIMaster, SPISlave from pslab.instrument.logic_analyzer import LogicAnalyzer from pslab.instrument.waveform_generator import PWMGenerator -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler SPI_SUPPORTED_DEVICES = [ # "PSLab vMOCK", # Uncomment after adding recording json files. diff --git a/tests/test_uart.py b/tests/test_uart.py index 961ae6d0..fd861e52 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -11,7 +11,7 @@ from pslab.bus.uart import UART from pslab.instrument.logic_analyzer import LogicAnalyzer from pslab.instrument.waveform_generator import PWMGenerator -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler WRITE_DATA = 0x55 TXD2 = "LA1" diff --git a/tests/test_waveform_generator.py b/tests/test_waveform_generator.py index 8dab0ac7..3f12c05a 100644 --- a/tests/test_waveform_generator.py +++ b/tests/test_waveform_generator.py @@ -19,7 +19,7 @@ from pslab.instrument.logic_analyzer import LogicAnalyzer from pslab.instrument.oscilloscope import Oscilloscope from pslab.instrument.waveform_generator import PWMGenerator, WaveformGenerator -from pslab.serial_handler import SerialHandler +from pslab.connection import SerialHandler MICROSECONDS = 1e-6 From 0605386f74f5929008dd7b8396e6bfe9933c6e92 Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 15:14:12 +0100 Subject: [PATCH 3/6] Deprecate serial_handler --- pslab/serial_handler.py | 516 ++-------------------------------------- 1 file changed, 17 insertions(+), 499 deletions(-) mode change 100755 => 100644 pslab/serial_handler.py diff --git a/pslab/serial_handler.py b/pslab/serial_handler.py old mode 100755 new mode 100644 index ead02f0d..4b05c1d1 --- a/pslab/serial_handler.py +++ b/pslab/serial_handler.py @@ -1,511 +1,29 @@ -"""Low-level communication for PSLab. +"""Deprecated provider of SerialHandler.""" -Example -------- ->>> from pslab.serial_handler import SerialHandler ->>> device = SerialHandler() ->>> version = device.get_version() ->>> device.disconnect() -""" +import warnings -from __future__ import annotations +from pslab.connection import SerialHandler, autoconnect -try: - import grp -except ImportError: - pass +warnings.warn( + "pslab.serial_handler is deprecated and will be removed in a future release. " + "Use pslab.connection instead." +) -import logging -import os -import platform -import struct -import time -from dataclasses import dataclass -from functools import partial, update_wrapper -from typing import List, Union - -import serial -from serial.tools import list_ports - -import pslab -import pslab.protocol as CP - -logger = logging.getLogger(__name__) - - -def detect(): - """Detect connected PSLab devices. - - Returns - ------- - devices : dict of str: str - Dictionary containing port name as keys and device version on that - port as values. - """ - regex = [] - - for vid, pid in zip(SerialHandler._USB_VID, SerialHandler._USB_PID): - regex.append(f"{vid:04x}:{pid:04x}") - - regex = "(" + "|".join(regex) + ")" - port_info_generator = list_ports.grep(regex) - pslab_devices = {} - - for port_info in port_info_generator: - version = _get_version(port_info.device) - if any(expected in version for expected in ["PSLab", "CSpark"]): - pslab_devices[port_info.device] = version - - return pslab_devices - - -def _get_version(port: str) -> str: - interface = serial.Serial(port=port, baudrate=1e6, timeout=1) - interface.write(CP.COMMON) - interface.write(CP.GET_VERSION) - version = interface.readline() - return version.decode("utf-8") - - -@dataclass -class FirmwareVersion: - """Version of pslab-firmware running on connected device. - - Uses semantic versioning conventions. - - Attributes - ---------- - major : int - Major version. Incremented when backward imcompatible changes are made. - minor : int - Minor version. Incremented when new functionality is added, or existing - functionality is changed in a backward compatible manner. - patch : int - Patch version. Incremented when bug fixes are made with do not change the - PSLab's documented behavior. - """ - - major: int - minor: int - patch: int - - -class SerialHandler: - """Provides methods for communicating with the PSLab hardware. - - When instantiated, SerialHandler tries to connect to the PSLab. A port can - optionally be specified; otherwise Handler will try to find the correct - port automatically. - - Parameters - ---------- - See :meth:`connect`. - """ - - # V5 V6 - _USB_VID = [0x04D8, 0x10C4] - _USB_PID = [0x00DF, 0xEA60] +class _SerialHandler(SerialHandler): def __init__( self, - port: str = None, + port: str | None = None, baudrate: int = 1000000, timeout: float = 1.0, - ): - self.version = "" - self.interface = serial.Serial() - - self.send_byte = partial(self._send, size=1) - update_wrapper(self.send_byte, self._send) - self.send_int = partial(self._send, size=2) - update_wrapper(self.send_int, self._send) - self.send_long = partial(self._send, size=4) - update_wrapper(self.send_long, self._send) - self.get_byte = partial(self._receive, size=1) - update_wrapper(self.get_byte, self._receive) - self.get_int = partial(self._receive, size=2) - update_wrapper(self.get_int, self._receive) - self.get_long = partial(self._receive, size=4) - update_wrapper(self.get_long, self._receive) - - self.check_serial_access_permission() - self.connect(port=port, baudrate=baudrate, timeout=timeout) - self.connected = self.interface.is_open - self.firmware = self.get_firmware_version() - - @staticmethod - def check_serial_access_permission(): - """Check that we have permission to use the tty on Linux.""" - if platform.system() == "Linux": - if os.geteuid() == 0: # Running as root? - return - - for group in os.getgroups(): - if grp.getgrgid(group).gr_name in ( - "dialout", - "uucp", - ): - return - - udev_paths = [ - "/run/udev/rules.d/", - "/etc/udev/rules.d/", - "/lib/udev/rules.d/", - ] - for p in udev_paths: - udev_rules = os.path.join(p, "99-pslab.rules") - if os.path.isfile(udev_rules): - return - else: - raise PermissionError( - "The current user does not have permission to access " - "the PSLab device. To solve this, either:" - "\n\n" - "1. Add the user to the 'dialout' (on Debian-based " - "systems) or 'uucp' (on Arch-based systems) group." - "\n" - "2. Install a udev rule to allow any user access to the " - "device by running 'pslab install' as root, or by " - "manually copying " - f"{pslab.__path__[0]}/99-pslab.rules into {udev_paths[1]}." - "\n\n" - "You may also need to reboot the system for the " - "permission changes to take effect." - ) - - @staticmethod - def _list_ports() -> List[str]: - """Return a list of serial port names.""" - return [p.device for p in list_ports.comports()] - - def connect( - self, - port: str = None, - baudrate: int = 1000000, - timeout: float = 1.0, - ): - """Connect to PSLab. - - Parameters - ---------- - port : str, optional - The name of the port to which the PSLab is connected as a string. - On Posix this is a path, e.g. "/dev/ttyACM0". On Windows, it's a - numbered COM port, e.g. "COM5". Will be autodetected if not - specified. If multiple PSLab devices are connected, port must be - specified. - baudrate : int, optional - Symbol rate in bit/s. The default value is 1000000. - timeout : float, optional - Time in seconds to wait before cancelling a read or write command. The - default value is 1.0. - - Raises - ------ - SerialException - If connection could not be established. - RuntimeError - If ultiple devices are connected and no port was specified. - """ - # serial.Serial opens automatically if port is not None. - self.interface = serial.Serial( - port=port, - baudrate=baudrate, - timeout=timeout, - write_timeout=timeout, - ) - pslab_devices = detect() - - if self.interface.is_open: - # User specified a port. - version = self.get_version() - else: - if len(pslab_devices) == 1: - self.interface.port = list(pslab_devices.keys())[0] - self.interface.open() - version = self.get_version() - elif len(pslab_devices) > 1: - found = "" - - for port, version in pslab_devices.items(): - found += f"{port}: {version}" - - raise RuntimeError( - "Multiple PSLab devices found:\n" - f"{found}" - "Please choose a device by specifying a port." - ) - else: - version = "" - - if self.interface.port in pslab_devices: - self.version = version - logger.info(f"Connected to {self.version} on {self.interface.port}.") - else: - self.interface.close() - self.version = "" - raise serial.SerialException("Device not found.") - - def disconnect(self): - """Disconnect from PSLab.""" - self.interface.close() - - def reconnect( - self, - port: str = None, - baudrate: int = None, - timeout: float = None, - ): - """Reconnect to PSLab. - - Will reuse previous settings (port, baudrate, timeout) unless new ones are - provided. - - Parameters - ---------- - See :meth:`connect`. - """ - self.disconnect() - - # Reuse previous settings unless user provided new ones. - baudrate = self.interface.baudrate if baudrate is None else baudrate - port = self.interface.port if port is None else port - timeout = self.interface.timeout if timeout is None else timeout - - self.connect( - port=port, - baudrate=baudrate, - timeout=timeout, - ) - - def get_version(self) -> str: - """Query PSLab for its version and return it as a decoded string. - - Returns - ------- - str - Version string. - """ - self.send_byte(CP.COMMON) - self.send_byte(CP.GET_VERSION) - version = self.interface.readline() - return version.decode("utf-8") - - def get_firmware_version(self) -> FirmwareVersion: - """Get firmware version. - - Returns - ------- - tuple[int, int, int] - major, minor, patch. - - """ - self.send_byte(CP.COMMON) - self.send_byte(CP.GET_FW_VERSION) - - try: - # Firmware version query was added in firmware version 3.0.0. - major = self.get_byte() - minor = self.get_byte() - patch = self.get_byte() - except serial.SerialException: - major = 2 - minor = 0 - patch = 0 - - return FirmwareVersion(major, minor, patch) - - def get_ack(self) -> int: - """Get response code from PSLab. - - Returns - ------- - int - Response code. Meanings: - 0x01 ACK - 0x10 I2C ACK - 0x20 I2C bus collision - 0x10 Radio max retransmits - 0x20 Radio not present - 0x40 Radio reply timout - """ - response = self.read(1) - - if not response: - raise serial.SerialException("Timeout while waiting for ACK.") - - ack = CP.Byte.unpack(response)[0] - - if not (ack & 0x01): - raise serial.SerialException("Received non ACK byte while waiting for ACK.") - - return ack - - @staticmethod - def _get_integer_type(size: int) -> struct.Struct: - if size == 1: - return CP.Byte - elif size == 2: - return CP.ShortInt - elif size == 4: - return CP.Integer - else: - raise ValueError("size must be 1, 2, or 4.") - - def _send(self, value: Union[bytes, int], size: int): - """Send a value to the PSLab. - - Parameters - ---------- - value : int - Value to send to PSLab. Must fit in four bytes. - """ - if isinstance(value, bytes): - packet = value - else: - packer = self._get_integer_type(size) - packet = packer.pack(value) - - self.write(packet) - - def _receive(self, size: int) -> int: - """Read and unpack data from the serial port. - - Returns - ------- - int - Unpacked data. - - Raises - ------ - SerialException if too few bytes received. - """ - received = self.read(size) - - if len(received) == size: - unpacker = self._get_integer_type(size) - retval = unpacker.unpack(received)[0] - else: - raise serial.SerialException( - f"Requested {size} bytes, got {len(received)}." - ) - - return retval - - def read(self, number_of_bytes: int) -> bytes: - """Read bytes from serial port. - - Parameters - ---------- - number_of_bytes : int - Number of bytes to read from the serial port. - - Returns - ------- - bytes - Bytes read from the serial port. - """ - return self.interface.read(number_of_bytes) - - def write(self, data: bytes) -> int: - """Write bytes to serial port. - - Parameters - ---------- - data : int - Bytes to write to the serial port. - - Returns - ------- - int - Number of bytes written. - """ - return self.interface.write(data) - - def wait_for_data(self, timeout: float = 0.2) -> bool: - """Wait for :timeout: seconds or until there is data in the input buffer. - - Parameters - ---------- - timeout : float, optional - Time in seconds to wait. The default is 0.2. - - Returns - ------- - bool - True iff the input buffer is not empty. - """ - start_time = time.time() - - while time.time() - start_time < timeout: - if self.interface.in_waiting: - return True - time.sleep(0.02) - - return False - - -class ADCBufferMixin: - """Mixin for classes that need to read or write to the ADC buffer.""" - - def fetch_buffer(self, samples: int, starting_position: int = 0): - """Fetch a section of the ADC buffer. - - Parameters - ---------- - samples : int - Number of samples to fetch. - starting_position : int, optional - Location in the ADC buffer to start from. By default samples will - be fetched from the beginning of the buffer. - - Returns - ------- - received : list of int - List of received samples. - """ - self._device.send_byte(CP.COMMON) - self._device.send_byte(CP.RETRIEVE_BUFFER) - self._device.send_int(starting_position) - self._device.send_int(samples) - received = [self._device.get_int() for i in range(samples)] - self._device.get_ack() - return received - - def clear_buffer(self, samples: int, starting_position: int = 0): - """Clear a section of the ADC buffer. - - Parameters - ---------- - samples : int - Number of samples to clear from the buffer. - starting_position : int, optional - Location in the ADC buffer to start from. By default samples will - be cleared from the beginning of the buffer. - """ - self._device.send_byte(CP.COMMON) - self._device.send_byte(CP.CLEAR_BUFFER) - self._device.send_int(starting_position) - self._device.send_int(samples) - self._device.get_ack() - - def fill_buffer(self, data: List[int], starting_position: int = 0): - """Fill a section of the ADC buffer with data. + ) -> None: + if port is None: + tmp_handler = autoconnect() + port = tmp_handler.port + tmp_handler.disconnect() - Parameters - ---------- - data : list of int - Values to write to the ADC buffer. - starting_position : int, optional - Location in the ADC buffer to start from. By default writing will - start at the beginning of the buffer. - """ - self._device.send_byte(CP.COMMON) - self._device.send_byte(CP.FILL_BUFFER) - self._device.send_int(starting_position) - self._device.send_int(len(data)) + super().__init__(port=port, baudrate=baudrate, timeout=timeout) + self.connect() - for value in data: - self._device.send_int(value) - self._device.get_ack() +SerialHandler = _SerialHandler From f595d01b51b8d3d2e7c6b8c8c1e4a051fcb793df Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 15:14:22 +0100 Subject: [PATCH 4/6] Add WLANHandler --- pslab/connection/__init__.py | 11 ++++ pslab/connection/wlan.py | 113 +++++++++++++++++++++++++++++++++++ tox.ini | 2 +- 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 pslab/connection/wlan.py diff --git a/pslab/connection/__init__.py b/pslab/connection/__init__.py index 0b5abcbe..fe7b50a0 100644 --- a/pslab/connection/__init__.py +++ b/pslab/connection/__init__.py @@ -4,6 +4,7 @@ from .connection import ConnectionHandler from ._serial import SerialHandler +from .wlan import WLANHandler def detect() -> list[ConnectionHandler]: @@ -36,6 +37,16 @@ def detect() -> list[ConnectionHandler]: finally: device.disconnect() + try: + device = WLANHandler() + device.connect() + except Exception: + pass # nosec + else: + pslab_devices.append(device) + finally: + device.disconnect() + return pslab_devices diff --git a/pslab/connection/wlan.py b/pslab/connection/wlan.py new file mode 100644 index 00000000..66be1534 --- /dev/null +++ b/pslab/connection/wlan.py @@ -0,0 +1,113 @@ +"""Wireless interface for communicating with PSLab devices equiped with ESP8266.""" + +import socket + +from pslab.connection.connection import ConnectionHandler + + +class WLANHandler(ConnectionHandler): + """Interface for controlling a PSLab over WLAN. + + Paramaters + ---------- + host : str, default 192.168.4.1 + Network address of the PSLab. + port : int, default 80 + timeout : float, default 1 s + """ + + def __init__( + self, + host: str = "192.168.4.1", + port: int = 80, + timeout: float = 1.0, + ) -> None: + self._host = host + self._port = port + self._timeout = timeout + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(timeout) + + @property + def host(self) -> int: + """Network address of the PSLab.""" + return self._host + + @property + def port(self) -> int: + """TCP port number.""" + return self._port + + @property + def timeout(self) -> float: + """Timeout in seconds.""" + return self._timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._sock.settimeout(value) + + def connect(self) -> None: + """Connect to PSLab.""" + if self._sock.fileno() == -1: + # Socket has been closed. + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(self.timeout) + + self._sock.connect((self.host, self.port)) + + try: + self.get_version() + except Exception: + self._sock.close() + raise + + def disconnect(self) -> None: + """Disconnect from PSLab.""" + self._sock.close() + + def read(self, numbytes: int) -> bytes: + """Read data over WLAN. + + Parameters + ---------- + numbytes : int + Number of bytes to read. + + Returns + ------- + data : bytes + """ + received = b"" + buf_size = 4096 + remaining = numbytes + + while remaining > 0: + chunk = self._sock.recv(min(remaining, buf_size)) + received += chunk + remaining -= len(chunk) + + return received + + def write(self, data: bytes) -> int: + """Write data over WLAN. + + Parameters + ---------- + data : bytes + + Returns + ------- + numbytes : int + Number of bytes written. + """ + return self._sock.sendall(data) + + def __repr__(self) -> str: # noqa + return ( + f"{self.__class__.__name__}" + "[" + f"{self.host}:{self.port}, " + f"timeout {self.timeout} s" + "]" + ) diff --git a/tox.ini b/tox.ini index a7ee8ac0..00be71d5 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ commands = coverage run --source pslab -m pytest [testenv:lint] deps = -rlint-requirements.txt setenv = - INCLUDE_PSL_FILES = pslab/bus/ pslab/instrument/ pslab/serial_handler.py pslab/cli.py pslab/external/motor.py pslab/external/gas_sensor.py pslab/external/hcsr04.py + INCLUDE_PSL_FILES = pslab/bus/ pslab/connection pslab/instrument/ pslab/serial_handler.py pslab/cli.py pslab/external/motor.py pslab/external/gas_sensor.py pslab/external/hcsr04.py commands = black --check {env:INCLUDE_PSL_FILES} flake8 --show-source {env:INCLUDE_PSL_FILES} From de365133a178b10a439ba14a6143bcfd7ea34987 Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 18:28:22 +0100 Subject: [PATCH 5/6] Remove timeout from handler repr's --- pslab/connection/_serial.py | 3 +-- pslab/connection/wlan.py | 8 +------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/pslab/connection/_serial.py b/pslab/connection/_serial.py index 691e8556..d7bf0061 100644 --- a/pslab/connection/_serial.py +++ b/pslab/connection/_serial.py @@ -153,7 +153,6 @@ def __repr__(self) -> str: # noqa f"{self.__class__.__name__}" "[" f"{self.port}, " - f"{self.baudrate} baud, " - f"timeout {self.timeout} s" + f"{self.baudrate} baud" "]" ) diff --git a/pslab/connection/wlan.py b/pslab/connection/wlan.py index 66be1534..ad3e8a44 100644 --- a/pslab/connection/wlan.py +++ b/pslab/connection/wlan.py @@ -104,10 +104,4 @@ def write(self, data: bytes) -> int: return self._sock.sendall(data) def __repr__(self) -> str: # noqa - return ( - f"{self.__class__.__name__}" - "[" - f"{self.host}:{self.port}, " - f"timeout {self.timeout} s" - "]" - ) + return f"{self.__class__.__name__}[{self.host}:{self.port}]" From 443fab534a04f8c497f2016f83ad075ddda06a1c Mon Sep 17 00:00:00 2001 From: Alexander Bessman Date: Wed, 19 Feb 2025 18:28:35 +0100 Subject: [PATCH 6/6] Fix WLANHandler.write returning None --- pslab/connection/wlan.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pslab/connection/wlan.py b/pslab/connection/wlan.py index ad3e8a44..15f2afe3 100644 --- a/pslab/connection/wlan.py +++ b/pslab/connection/wlan.py @@ -101,7 +101,16 @@ def write(self, data: bytes) -> int: numbytes : int Number of bytes written. """ - return self._sock.sendall(data) + buf_size = 4096 + remaining = len(data) + sent = 0 + + while remaining > 0: + chunk = data[sent : min(remaining, buf_size)] + sent += self._sock.send(chunk) + remaining -= len(chunk) + + return sent def __repr__(self) -> str: # noqa return f"{self.__class__.__name__}[{self.host}:{self.port}]"