diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6609df1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# PyCharm +.idea + +# VScode +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5c8e54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Sören Sprößig + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 23c891c..b42145c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,68 @@ -# Magnetfeldteststand +# Python-PS2000B +Python library to work with Elektro-Automatik PS 2000 B power supplies via USB. + +## Compatibility +Tested with: + ++ Python 2.7 ++ Python 3.5, 3.6 + +Tested on: + ++ Windows 7 x64 ++ Windows 10 x64 Version 1607 (OS Build 14393.2035) ++ Ubuntu 16.04.1 LTS ++ Ubuntu 20.04.1 LTS + +## Features of Python-PS2000B +### Supported +- read static device information (manufacturer, serial, device type ...) +- read dynamic device information (current, voltage) +- read/write remote control +- read/write output control + +### Still todo +- set voltage and current +- wrap error results in own telegram + +## Prerequisites + +### Python +The following third-party Python libraries are needed: + +* `pyserial` - serial communication library for Python, see https://pypi.python.org/pypi/pyserial + +### Windows +On Windows the USB driver (fetch it from http://www.elektroautomatik.de/files/eautomatik/treiber/usb/ea_device_driver.rar) must be installed. Afterwards you can find the serial port `COMxx` in the *device manager*. + +### Linux +On Linux the device is detected as `/dev/ttyACMx` (abstract control model, see https://www.rfc1149.net/blog/2013/03/05/what-is-the-difference-between-devttyusbx-and-devttyacmx/). Use `dmesg` after connecting the device to find out `x`. + +Most Linuxes will require users to elevate for accessing the device. If you want to access the device as your current user, just add it to the group `dialout` (`ls -lah /dev/ttyACM0` will show you the group to use, usually this is `dialout`) and login again. + +## Usage +```python +import time +from pyps2000b import PS2000B + + +device = PS2000B.PS2000B("/dev/ttyACM0") + +print("Device status: %s" % device.get_device_status_information()) + +device.enable_remote_control() +device.voltage1 = 5.1 +device.current1 = 1 +device.enable_output() + +time.sleep(1) + +print("Current output: %0.2f V , %0.2f A" % (device.get_voltage(), device.get_current())) + +device.output1 = False +``` + +## Documentation ++ product website: http://www.elektroautomatik.de/en/ps2000b.html ++ programming guide PS 2000 B: http://www.elektroautomatik.de/files/eautomatik/treiber/ps2000b/programming_ps2000b.zip diff --git a/Testing1.py b/Testing1.py new file mode 100644 index 0000000..9eb1f2d --- /dev/null +++ b/Testing1.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# coding=utf-8 +import platform +import time + +from pyps2000b import PS2000B + + +DEVICE = "COM7" if platform.system() == "Windows" else "/dev/ttyACM0" + +# connection to the device is automatically opened +print("Connecting to device at %s..." % DEVICE) +device1 = PS2000B.PS2000B(DEVICE) # create Object of class PS2000B, pass COM-port and channel to functions inside + +# static device information can be read +print("Connection open: %s" % device1.is_open()) +print("Device: %s" % device1.get_device_information()) + +# dynamic device status information can be read +device_status_info1 = device1.get_device_status_information(0) +device_status_info2 = device1.get_device_status_information(1) +print("Device status 1: %s" % device1.get_device_status_information(0)) +print("Device status 2: %s" % device1.get_device_status_information(1)) +print("Current output 1: %0.2f V , %0.2f A" % (device1.voltage1, device1.current1)) +print("Current output 2: %0.2f V , %0.2f A" % (device1.voltage2, device1.current2)) + +# device can be controlled +if not device_status_info1.remote_control_active: + print("...will enable remote control...") + device1.enable_remote_control(0) +if not device_status_info2.remote_control_active: + print("...will enable remote control...") + device1.enable_remote_control(1) + +print("...set voltage 1 to 12V and max current to 1A...") +device1.voltage1 = 12 +device1.current1 = 1 +time.sleep(2) +print("...now enabling the power output control 1...") +device1.enable_output(0) + +time.sleep(2) +print("... set voltage 2 to 5V and max current to 1A...") +device1.voltage2 = 5 +device1.current2 = 1 +time.sleep(2) +print("...now enabling the power output control 2...") +device1.enable_output(1) + +time.sleep(5) +print("Device status 1: %s" % device1.get_device_status_information(0)) +print("Device status 2: %s" % device1.get_device_status_information(1)) +print("Current output 1: %0.2f V , %0.2f A" % (device1.voltage1, device1.current1)) +print("Current output 2: %0.2f V , %0.2f A" % (device1.voltage2, device1.current2)) + +time.sleep(5) +device1.disable_output(0) +device1.disable_output(1) +print("...and disabling remote control again.") +device1.disable_remote_control(0) +device1.disable_remote_control(1) + +print("Device status 1: %s" % device1.get_device_status_information(0)) +print("Device status 2: %s" % device1.get_device_status_information(1)) diff --git a/example.py b/example.py new file mode 100644 index 0000000..0ac2a09 --- /dev/null +++ b/example.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# coding=utf-8 +import platform +import time + +from pyps2000b import PS2000B + + +DEVICE = "COM7" if platform.system() == "Windows" else "/dev/ttyACM0" + +# connection to the device is automatically opened +print("Connecting to device at %s..." % DEVICE) +device = PS2000B.PS2000B(DEVICE) # create Object of class PS2000B, pass COM-port to functions inside + +# static device information can be read +print("Connection open: %s" % device.is_open()) +print("Device: %s" % device.get_device_information()) + +# dynamic device status information can be read +device_status_info1 = device.get_device_status_information(0) +device_status_info2 = device.get_device_status_information(1) +print("Device status 1: %s" % device_status_info1) +print("Device status 2: %s" % device_status_info2) + +print("Current output: %0.2f V , %0.2f A" % (device.get_voltage(), device.get_current())) + +# device can be controlled +if not device_status_info.remote_control_active: + print("...will enable remote control...") + device.enable_remote_control() + +print("...set voltage to 12V and max current to 1A...") +device.voltage1 = 12 +device.current1 = 1 +time.sleep(10) +print("...now enabling the power output control...") +device.enable_output() +time.sleep(2) +device_status_info1 = device.get_device_status_information(0) +device_status_info2 = device.get_device_status_information(1) +print("Device status 1: %s" % device_status_info1) +print("Device status 2: %s" % device_status_info2) +print("Current output: %0.2f V , %0.2f A" % (device.voltage1, device.current1)) +time.sleep(10) +print("...set voltage to 5.1V...") +device.voltage1 = 5.1 +time.sleep(10) +print("Current output: %0.2f V , %0.2f A" % (device.get_voltage(), device.get_current())) +print("...after 5 seconds power output will be disabled again ...") +time.sleep(5) +device.disable_output() +print("...and disabling remote control again.") +device.disable_remote_control() + +print("Device status 1: %s" % device.get_device_status_information(0)) +print("Device status 2: %s" % device.get_device_status_information(1)) diff --git a/pyps2000b/PS2000B.py b/pyps2000b/PS2000B.py new file mode 100644 index 0000000..321bc1d --- /dev/null +++ b/pyps2000b/PS2000B.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +# coding=utf-8 +# Python access to Elektro Automatik PS 2000 B devices via USB/serial +# +# Supported features: +# - read static device information (manufacturer, serial, device type ...) +# - read dynamic device information (current, voltage) +# - read/write remote control +# - read/write output control +# +# Todo: +# - wrap error results in own telegram +# +# References +# [1] = "PS 2000B Programming Guide" from 2015-05-28 +# [2] = "PS 2000B object list" +# + +import serial +import struct +import sys +PY_3 = sys.version_info >= (3, 0) + + +__author__ = "Sören Sprößig " + + +def as_string(raw_data): + """Converts the given raw bytes to a string (removes NULL)""" + return bytearray(raw_data[:-1]) + + +def as_float(raw_data): + """Converts the given raw bytes to a float""" + f = struct.unpack_from(">f", bytearray(raw_data))[0] + return f + + +def as_word(raw_data): + """Converts the given raw bytes to a word""" + w = struct.unpack_from(">H", bytearray(raw_data))[0] + return w + + +def _ord(x): + """Wrap ord() call as we only need it in Python 2""" + return x if PY_3 else ord(x) + + +# noinspection PyClassHasNoInit +class Constants: + """Communication constants""" + # communication parameters taken from [1], chapter 2.2 + CONNECTION_BAUD_RATE = 115200 + CONNECTION_PARITY = serial.PARITY_ODD + CONNECTION_STOP_BITS = 1 + # timeout taken from [1], chapter 3.7 + TIMEOUT_BETWEEN_COMMANDS = 0.05 + # according to spec [1] 2.4: + # maximum length of a telegram is 21 bytes (Byte 0..20) + MAX_LEN_IN_BYTES = 21 + + +# noinspection PyClassHasNoInit +class Objects: + """Supported objects ids / commands""" + DEVICE_TYPE = 0 + DEVICE_SERIAL_NO = 1 + NOMINAL_VOLTAGE = 2 + NOMINAL_CURRENT = 3 + NOMINAL_POWER = 4 + DEVICE_ARTICLE_NO = 6 + MANUFACTURER = 8 + SOFTWARE_VERSION = 9 + SET_VALUE_VOLTAGE = 50 + SET_VALUE_CURRENT = 51 + POWER_SUPPLY_CONTROL = 54 + STATUS_ACTUAL_VALUES = 71 + + +# noinspection PyClassHasNoInit +class ControlParameters: + """Parameters for controlling the device""" + SWITCH_MODE_CMD = 0x10 + SWITCH_MODE_REMOTE = 0x10 + SWITCH_MODE_MANUAL = 0x00 + SWITCH_POWER_OUTPUT_CMD = 0x1 + SWITCH_POWER_OUTPUT_ON = 0x1 + SWITCH_POWER_OUTPUT_OFF = 0x0 + + +class Telegram: + """Base class of a PS2000B telegram - basically allows accessing the raw bytes and does checksum calculation""" + + def __init__(self): + self._bytes = [] + self._checksum = [] + self.checksum_ok = False + + def _calc_checksum(self): + cs = 0 + + for b in self._bytes: + cs += b + + cs_high = (cs & 0xff00) >> 8 + cs_low = cs & 0xff + + return [cs_high, cs_low] + + @staticmethod + def _get_start_delimiter(transmission, expected_data_length): + result = 0b00000000 + + if expected_data_length > 16: + raise Exception("only 4 bits for expected length can be used") + + result |= (expected_data_length - 1) + result |= 0b10000 + result |= 0b100000 + result |= transmission << 6 + return result + + def get_byte_array(self): + return bytearray(self._bytes + self._checksum) + + +class FromPowerSupply(Telegram): + """Telegram received from the power supply""" + + def __init__(self, raw_data): + Telegram.__init__(self) + data = [_ord(x) for x in raw_data] + self._bytes = data[0:-2] + self._checksum = data[len(data) - 2:len(data)] + self.checksum_ok = self._checksum == self._calc_checksum() + + def get_sd(self): + return self._bytes[0] + + def get_device_node(self): + return self._bytes[1] + + def get_object(self): + return self._bytes[3] + + def get_data(self): + return self._bytes[3:len(self._bytes)] + + # noinspection PyMethodMayBeStatic + def get_error(self): + # FIXME: [1] chapter 3.6 add support for error codes here + return None + + +class ToPowerSupply(Telegram): + """A telegram sent to the power supply""" + + def __init__(self, transmission, data, expected_data_length): + Telegram.__init__(self) + self._bytes = [] + self._bytes.append(self._get_start_delimiter(transmission, expected_data_length)) + self._bytes.extend(data) + self._checksum = self._calc_checksum() + self.checksum_ok = True + + +class DeviceInformation: + """A class carrying all static device information read from the device""" + + def __init__(self): + self.device_type = "" + self.device_serial_no = "" + self.nominal_voltage = 0 + self.nominal_current = 0 + self.nominal_power = 0 + self.manufacturer = "" + self.device_article_number = "" + self.software_version = "" + + def __str__(self): + return "%s %s [%s], SW: %s, Art-Nr: %s, [%0.2f V, %0.2f A, %0.2f W]" % \ + (self.manufacturer, + self.device_type, self.device_serial_no, + self.software_version, self.device_article_number, + self.nominal_voltage, self.nominal_current, self.nominal_power) + + +class DeviceStatusInformation: + """A class carrying all dynamic device status information""" + + def __init__(self, raw_data): + self.remote_control_active = raw_data[0] & 0b1 + self.output_active = raw_data[1] & 0b1 + self.actual_voltage_percent = float(as_word(raw_data[2:4])) / 256 + self.actual_current_percent = float(as_word(raw_data[4:6])) / 256 + + def __str__(self): + return "Remote control active: %s, Output active: %s" % (self.remote_control_active, self.output_active) + + +class PS2000B: + """PS 2000 B main communication class""" + + def __init__(self, serial_port): + self.__device_status_information1 = None + self.__device_status_information2 = None + self.__serial = serial.Serial(serial_port, + baudrate=Constants.CONNECTION_BAUD_RATE, + timeout=Constants.TIMEOUT_BETWEEN_COMMANDS * 2, + parity=serial.PARITY_ODD, + stopbits=Constants.CONNECTION_STOP_BITS) + + self.__device_information = self.__read_device_information() + + def is_open(self): + return self.__serial.is_open + + def get_device_information(self): + return self.__device_information + + def __read_device_information(self, channel=0): + result = DeviceInformation() + + # taken from [2] + result.device_type = as_string(self.__read_device_data(16, Objects.DEVICE_TYPE, channel).get_data()) + result.device_serial_no = as_string(self.__read_device_data(16, Objects.DEVICE_SERIAL_NO, channel).get_data()) + result.nominal_voltage = as_float(self.__read_device_data(4, Objects.NOMINAL_VOLTAGE, channel).get_data()) + result.nominal_current = as_float(self.__read_device_data(4, Objects.NOMINAL_CURRENT, channel).get_data()) + result.nominal_power = as_float(self.__read_device_data(4, Objects.NOMINAL_POWER, channel).get_data()) + result.device_article_number = as_string(self.__read_device_data(16, Objects.DEVICE_ARTICLE_NO, channel).get_data()) + result.manufacturer = as_string(self.__read_device_data(16, Objects.MANUFACTURER, channel).get_data()) + result.software_version = as_string(self.__read_device_data(16, Objects.SOFTWARE_VERSION, channel).get_data()) + + return result + + def __read_device_data(self, expected_length, object_id, channel): + telegram = ToPowerSupply(0b01, [channel, object_id], expected_length) + result = self.__send_and_receive(telegram.get_byte_array()) + return result + + def __send_and_receive(self, raw_bytes): + self.__serial.write(raw_bytes) + result = FromPowerSupply(self.__serial.read(Constants.MAX_LEN_IN_BYTES)) + return result + + def get_device_status_information(self, channel=0): + """:returns DeviceStatusInformation""" + if channel == 0: + if self.__device_status_information1 is None: + self.update_device_information(0) + info = self.__device_status_information1 + elif channel == 1: + if self.__device_status_information2 is None: + self.update_device_information(1) + info = self.__device_status_information2 + else: + info = None + raise ValueError("Invalid Channel") + return info + + def update_device_information(self, channel=0): + telegram = ToPowerSupply(0b01, [channel, Objects.STATUS_ACTUAL_VALUES], 6) + device_information = self.__send_and_receive(telegram.get_byte_array()) + if channel == 0: + self.__device_status_information1 = DeviceStatusInformation(device_information.get_data()) + elif channel == 1: + self.__device_status_information2 = DeviceStatusInformation(device_information.get_data()) + else: + raise ValueError("Invalid Channel") + + def __send_device_control(self, p1, p2, channel=0): + telegram = ToPowerSupply(0b11, [channel, Objects.POWER_SUPPLY_CONTROL, p1, p2], 2) + _ = self.__send_and_receive(telegram.get_byte_array()) + self.update_device_information(channel) + + def __send_device_data(self, obj, data, channel): + ''' + Send integer data with obj-id to the PSU + ''' + telegram = ToPowerSupply(0b11, [channel, obj, data >> 8, data & 0xff], 4) + _ = self.__send_and_receive(telegram.get_byte_array()) + self.update_device_information(channel) + + def enable_remote_control(self, channel): + self.__send_device_control(ControlParameters.SWITCH_MODE_CMD, ControlParameters.SWITCH_MODE_REMOTE, channel) + + def disable_remote_control(self, channel): + self.__send_device_control(ControlParameters.SWITCH_MODE_CMD, ControlParameters.SWITCH_MODE_MANUAL, channel) + + def enable_output(self, channel): + self.__send_device_control(ControlParameters.SWITCH_POWER_OUTPUT_CMD, ControlParameters.SWITCH_POWER_OUTPUT_ON, channel) + + def disable_output(self, channel): + self.__send_device_control(ControlParameters.SWITCH_POWER_OUTPUT_CMD, ControlParameters.SWITCH_POWER_OUTPUT_OFF, channel) + + @property + def output1(self,): + return self.get_device_status_information(0).output_active + + @output1.setter + def output1(self, value): + if value: + self.enable_output() + else: + self.disable_output() + + def get_voltage(self, channel=0): + self.update_device_information(channel) + if channel == 0: + v_perc = self.__device_status_information1.actual_voltage_percent + elif channel == 1: + v_perc = self.__device_status_information2.actual_voltage_percent + else: + raise ValueError("Invalid channel") + voltage = self.__device_information.nominal_voltage * v_perc + return voltage / 100 + + def get_voltage_setpoint(self, channel=0): + res = self.__read_device_data(2, Objects.SET_VALUE_VOLTAGE, channel).get_data() + return self.__device_information.nominal_voltage * ((res[0]<<8) + res[1]) / 25600.0 + + def set_voltage(self, value, channel): + self.update_device_information(channel) + self.enable_remote_control(channel) + volt = int(round( (value * 25600.0) / self.__device_information.nominal_voltage )) + self.__send_device_data(Objects.SET_VALUE_VOLTAGE, volt, channel) + #self.disable_remote_control() + + @property + def voltage1(self): + return self.get_voltage(0) + + @voltage1.setter + def voltage1(self, value): # voltage of channel 1 + self.set_voltage(value, 0) + + @property + def voltage2(self): + return self.get_voltage(1) + + @voltage2.setter + def voltage2(self, value): # voltage of channel 2 + self.set_voltage(value, 1) + + def get_current(self, channel=0): + self.update_device_information(channel) + if channel == 0: + c_perc = self.__device_status_information1.actual_current_percent + elif channel == 1: + c_perc = self.__device_status_information2.actual_current_percent + else: + raise ValueError("Invalid channel") + current = self.__device_information.nominal_current * c_perc + return current / 100 + + def get_current_setpoint(self, channel=0): + res = self.__read_device_data(2, Objects.SET_VALUE_CURRENT, channel).get_data() + return self.__device_information.nominal_current * ((res[0] << 8) + res[1]) / 25600.0 + + def set_current(self, value, channel): + self.update_device_information(channel) + self.enable_remote_control(channel) + curr = int(round( (value * 25600.0) / self.__device_information.nominal_current)) + self.__send_device_data(Objects.SET_VALUE_CURRENT, curr, channel) + # self.disable_remote_control() + + @property + def current1(self): + return self.get_current(0) + + @current1.setter + def current1(self, value): # current of channel 1 + self.set_current(value, 0) + + @property + def current2(self): + return self.get_current(1) + + @current2.setter + def current2(self, value): # current of channel 2 + self.set_current(value, 1) diff --git a/pyps2000b/__init__.py b/pyps2000b/__init__.py new file mode 100644 index 0000000..66047dc --- /dev/null +++ b/pyps2000b/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python +# coding=utf-8 +__author__ = "Sören Sprößig "