diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1772847 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9cabb7e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pyprologix.iml b/.idea/pyprologix.iml new file mode 100644 index 0000000..039314d --- /dev/null +++ b/.idea/pyprologix.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/MeasureAll.py b/MeasureAll.py new file mode 100644 index 0000000..5a904fc --- /dev/null +++ b/MeasureAll.py @@ -0,0 +1,167 @@ +import threading +import time +import matplotlib.pyplot as plt +from collections import deque +from matplotlib.widgets import Button +from pm2534 import pm2534 +from hp3478a import hp3478a + +# Initialisiere die Messgeräte (pm2534 und hp3478a) +device_1 = pm2534(22, "COM11", debug=True) +device_1.setSpeed(device_1.Speeds.Speed1) +device_1.setRange(3E0) +device_2 = hp3478a(23, "COM12", debug=False) +device_2.setRange("3") + +Geraet1 = 'PM2534' +Geraet2 = 'HP3478A' + +# Betriebsmodus für die Geräte +mode_device_1 = "Mode A" +mode_device_2 = "Mode A" + +# Datenpuffer für jedes Messgerät (deque für die Echtzeitdaten) +data_1 = deque(maxlen=100) # (time, value) +data_2 = deque(maxlen=100) # (time, value) + +# Steuerung für das Beenden der Threads +stop_threads = False +pause_plotting = False # Flag für das Pausieren des Neuzeichnens + +# Echtzeitplot mit Matplotlib (muss im Hauptthread laufen) +plt.ion() # Interaktiver Modus für Echtzeitplot +fig, ax = plt.subplots() # Nur eine Achse ohne Histogramm +plt.subplots_adjust(bottom=0.4) # Platz für Schaltflächen + +# Annotation für Mouseover +annotation = ax.annotate("", xy=(0,0), xytext=(20,20), + textcoords="offset points", + bbox=dict(boxstyle="round", fc="w"), + arrowprops=dict(arrowstyle="->")) +annotation.set_visible(False) + +# Funktion zur Berechnung der Zeitdifferenz in Sekunden +def time_diff(start_time): + return [t - start_time for t, _ in data_1], [t - start_time for t, _ in data_2] + +# Hauptplot-Funktion, die die Werte in Echtzeit darstellt +def update_plot(): + ax.clear() + + if data_1 and data_2: + start_time = data_1[0][0] # Starte von der ersten Messung des ersten Geräts + + # Extrahiere Zeiten und Werte + times_1, values_1 = zip(*data_1) + times_2, values_2 = zip(*data_2) + + # Konvertiere Zeiten in Differenzen zur ersten Messung (in Sekunden) + times_1 = [t - start_time for t in times_1] + times_2 = [t - start_time for t in times_2] + + ax.plot(times_1, values_1, label=Geraet1, marker='*', linestyle='-') + ax.plot(times_2, values_2, label=Geraet2, marker='*', linestyle='-') + + ax.legend() + ax.set_xlabel('Zeit (s)') + ax.set_ylabel('Messwert') + ax.grid(True) + + plt.draw() + +# Funktion, die in einem Thread für jedes Messgerät läuft und Werte liest +def read_device1(device, data_queue, mode): + global stop_threads + while not stop_threads: + value = device.getMeasure() + timestamp = time.time() # Erfasse die aktuelle Zeit + data_queue.append((timestamp, value)) # Speichere Zeit und Wert + time.sleep(3) + +def read_device2(device, data_queue, mode): + global stop_threads + while not stop_threads: + value = device.getMeasure() + timestamp = time.time() # Erfasse die aktuelle Zeit + data_queue.append((timestamp, value)) # Speichere Zeit und Wert + time.sleep(3) + +# Threads für jedes Gerät starten +thread_1 = threading.Thread(target=read_device1, args=(device_1, data_1, mode_device_1)) +thread_2 = threading.Thread(target=read_device2, args=(device_2, data_2, mode_device_2)) +thread_1.start() +thread_2.start() + +# Schaltflächen für das Umschalten der Betriebsmodi und Pausieren +ax_button_reset = plt.axes([0.4, 0.1, 0.15, 0.075]) # Position für den Reset-Button +ax_button_pause = plt.axes([0.1, 0.1, 0.15, 0.075]) # Position für den Pause-Button + +button_reset = Button(ax_button_reset, 'Reset Daten') # Reset-Button +button_pause = Button(ax_button_pause, 'Pause Plotting') # Pause-Button + +# Funktion zum Zurücksetzen der Datenpuffer +def reset_data(event): + global data_1, data_2 + data_1.clear() # Leert den Datenpuffer von Gerät 1 + data_2.clear() # Leert den Datenpuffer von Gerät 2 + print("Datenpuffer geleert") + +# Funktion zum Pausieren des Neuzeichnens +def toggle_pause(event): + global pause_plotting + pause_plotting = not pause_plotting # Toggle den Zustand + button_pause.label.set_text('Resume Plotting' if pause_plotting else 'Pause Plotting') # Ändere den Button-Text + print("Neuzeichnen der Grafik pausiert." if pause_plotting else "Neuzeichnen der Grafik fortgesetzt.") + +# Binde die Schaltflächen an die Callback-Funktionen +button_reset.on_clicked(reset_data) # Reset-Button an die Funktion binden +button_pause.on_clicked(toggle_pause) # Pause-Button an die Funktion binden + +# Funktion, die ausgeführt wird, wenn das Fenster geschlossen wird +def on_close(event): + global stop_threads + stop_threads = True # Threads stoppen + plt.close('all') + +# Event-Handler für das Schließen des Fensters binden +fig.canvas.mpl_connect('close_event', on_close) + +def on_mouse_move(event): + if event.inaxes == ax: # Überprüfen, ob die Maus innerhalb der Achsen ist + # Überprüfen, ob ein Punkt in der Nähe ist (für Gerät 1) + for i, (t, y) in enumerate(data_1): + if abs(event.xdata - (t - data_1[0][0])) < 0.2 and abs(event.ydata - y) < 0.2: + annotation.xy = (t - data_1[0][0], y) + annotation.set_text(f"{Geraet1}: {y:.2f}") + annotation.set_visible(True) + break + else: # Nur wenn kein Punkt gefunden wurde, wird die Annotation ausgeblendet + annotation.set_visible(False) + + # Überprüfen für Gerät 2 + for i, (t, y) in enumerate(data_2): + if abs(event.xdata - (t - data_2[0][0])) < 0.2 and abs(event.ydata - y) < 0.2: + annotation.xy = (t - data_2[0][0], y) + annotation.set_text(f"{Geraet2}: {y:.2f}") + annotation.set_visible(True) + break + else: + annotation.set_visible(False) + + fig.canvas.draw_idle() # Aktualisiere die Darstellung + +# Event-Handler für Mouseover binden +fig.canvas.mpl_connect('motion_notify_event', on_mouse_move) + +# Endlos-Schleife zur Aktualisierung des Plots +try: + while not stop_threads: + if not pause_plotting: + update_plot() + plt.pause(2) +finally: + # Sauberes Beenden der Threads + stop_threads = True + thread_1.join() + thread_2.join() + print("Programm beendet.") diff --git a/README.md b/README.md index d15b720..42f1720 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ Most functions are supported. Additionally you can read calibration SRAM data to Not yet implemented, WIP +### Siglent SDM3065x + +Communicates over Network + ### IEEE488.2/SCPI standard Not yet implemented diff --git a/bm869s.py b/bm869s.py new file mode 100644 index 0000000..42b9e00 --- /dev/null +++ b/bm869s.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2021 TheHWcave +# +# 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. +# + +# +# Implements a class that can read Brymen BM869S (and similar) meters +# using the Brymen USB interface cable +# +import argparse +import hid, sys +from time import sleep, time, localtime, strftime, perf_counter + +VID = 0x0820 +PID = 0x0001 + +# +# Note: +# The remote interface of the BM meters is actually a copy +# of the segments activated on the LCD display. This means for +# the values we have to convert 7-segment back into numbers. +# and interpret the various annouciators on the LCD. This makes +# unfortunately for a very dense piece of software. You need to +# consult the Brymen documentation of the LCD to understand what +# is going on... +# +# aaaaa +# f b +# f b +# ggggg +# e c +# e c +# xx ddddd +# +# bgcdafex +SEVSEG = {'00000000': ' ', + '00000001': '. ', + '10111110': ' 0', + '10111111': '.0', + '10100000': ' 1', + '10100001': '.1', + '11011010': ' 2', + '11011011': '.2', + '11111000': ' 3', + '11111001': '.3', + '11100100': ' 4', + '11100101': '.4', + '01111100': ' 5', + '01111101': '.5', + '01111110': ' 6', + '01111111': '.6', + '10101000': ' 7', + '10101001': '.7', + '11111110': ' 8', + '11111111': '.8', + '11111100': ' 9', + '11111101': '.9', + '00010110': ' L', + '00010111': '.L', + '11110010': ' d', + '00100000': ' i', + '01110010': ' o', + '11110010': ' d', + '01100010': ' n', + '01011110': ' E', + '01000010': ' r', + '00011110': ' C', + '01001110': ' F'} + + +class BM869S: + _h: None + _DBYTES = bytearray(24) + _DBITS = ['00000000'] * 24 + _mdsp = '' + _mmode = '' + _sdsp = '' + _smode = '' + _msg = '\x00\x00\x86\x66' + + def __init__(self): + + self._h = hid.Device(VID, PID) + + def Store(self, chunk, data): + # print(str(chunk)+' '+str(len(data))+':',end='') + # for b in data: + # print(hex(b)+' ',end='') + # print() + self._DBYTES[8 * chunk:8 * chunk + 7] = data + n = 0 + for b in data: + self._DBITS[8 * chunk + n] = format(b, '08b') + n = n + 1 + + def Decode(self): + # print(self._DBITS) + + # ------------------------------------------------------------------ + # main display mode and range + self._mmode = '' + if self._DBITS[1][3] == '1' and self._DBITS[2][7] == '1': + self._mmode = 'AC+DC ' + elif self._DBITS[1][3] == '1': + self._mmode = 'DC ' + elif self._DBITS[2][7] == '1': + self._mmode = 'AC ' + if self._mmode != '': + if self._DBITS[15][4] == '1': self._mmode = self._mmode + 'u' + if self._DBITS[15][5] == '1': self._mmode = self._mmode + 'm' + if self._DBITS[14][0] == '1': self._mmode = self._mmode + 'A' + if self._DBITS[8][7] == '1': self._mmode = self._mmode + 'V' + else: + if self._DBITS[15][7] == '1': + self._mmode = self._mmode + 'HZ' + if self._DBITS[15][1] == '1': self._mmode = 'k' + self._mmode + if self._DBITS[15][2] == '1': self._mmode = 'M' + self._mmode + elif self._DBITS[15][6] == '1': + self._mmode = self._mmode + 'dB' + elif self._DBITS[15][0] == '1': + self._mmode = self._mmode + 'D%' + if self._mmode == '': + if self._DBITS[2][5] == '1': + self._mmode = 'T1-T2' + elif self._DBITS[2][4] == '1': + self._mmode = 'T2' + elif self._DBITS[2][6] == '1': + self._mmode = 'T1' + if self._mmode == '': + if self._DBITS[15][3] == '1': + self._mmode = 'OHM' + if self._DBITS[15][1] == '1': self._mmode = 'k' + self._mmode + if self._DBITS[15][2] == '1': self._mmode = 'M' + self._mmode + + elif self._DBITS[14][2] == '1': + self._mmode = 'F' + if self._DBITS[14][1] == '1': self._mmode = 'n' + self._mmode + if self._DBITS[15][4] == '1': self._mmode = 'u' + self._mmode + if self._DBITS[15][5] == '1': self._mmode = 'm' + self._mmode + elif self._DBITS[14][3] == '1': + self._mmode = 'S' + if self._DBITS[14][1] == '1': self._mmode = 'n' + self._mmode + # ------------------------------------------------------------------ + # secondary display mode and range + + self._smode = '' + if self._DBITS[9][2] == '1': + self._smode = 'AC ' + elif self._DBITS[14][4] == '1' or self._DBITS[9][5] == '1' or self._DBITS[9][4] == '1': + self._smode = 'DC ' + if self._smode != '': + if self._DBITS[9][7] == '1': self._smode = self._smode + 'u' + if self._DBITS[9][6] == '1': self._smode = self._smode + 'm' + if self._DBITS[9][5] == '1': self._smode = self._smode + 'A' + if self._DBITS[9][4] == '1': self._smode = self._smode + '%4-20mA' + if self._DBITS[14][4] == '1': self._smode = self._smode + 'V' + else: + if self._DBITS[14][5] == '1': + self._smode = self._smode + 'HZ' + if self._DBITS[14][6] == '1': self._smode = 'k' + self._smode + if self._DBITS[14][7] == '1': self._smode = 'M' + self._smode + if self._smode == '': + if self._DBITS[9][1] == '1': self._smode = 'T2' + + # ------------------------------------------------------------------ + # signs for main and secondary displays + if self._DBITS[2][0] == '1': + self._mdsp = '-' + else: + self._mdsp = '' + + if self._DBITS[9][3] == '1': + self._sdsp = '-' + else: + self._sdsp = '' + # ------------------------------------------------------------------ + # main display digits + for n in range(3, 9): + v = self._DBITS[n] + # print(str(n)+' '+v+' = ',end='') + if v in SEVSEG: + digit = SEVSEG[v] + else: + digit = ' ?' + if digit[0] == ' ' or (n == 3) or (n == 8): + self._mdsp = self._mdsp + digit[1] + else: + self._mdsp = self._mdsp + digit + if self._mmode.startswith('T'): + self._mmode = self._mmode + ' ' + self._mdsp[-1:] + self._mdsp = self._mdsp[:-1] + # print(self._mdsp+' '+self._mmode) + # ------------------------------------------------------------------ + # secondary display digits + for n in range(10, 14): + v = self._DBITS[n] + # print(str(n)+' '+v+' = ',end='') + if v in SEVSEG: + digit = SEVSEG[v] + else: + digit = ' ?' + if digit[0] == ' ' or n == 10: + self._sdsp = self._sdsp + digit[1] + else: + self._sdsp = self._sdsp + digit + # print(self._sdsp+' '+self._smode) + return (self._mdsp, self._mmode, self._sdsp, self._smode) + + def readdata(self): + """ + returns the data from the BM869S in form of a list with 4 entries + entry + 0: the number as shown on the main display of the BM869S + 1: the unit&mode belonging to the main display, e.g. "mVDC" or "OHM" + 2: the number as shown on the secondary display (or blank if no secondary display is active) + 3: the unit&mode belonging to the secondary display, e.g. "HZ" (or blank) + """ + self._h.write(self._msg.encode('latin1')) + chunk = 0 + res = '' + Done = False + while not Done: + x = self._h.read(24, 4000) + if len(x) > 0: + self.Store(chunk, x) + chunk = chunk + 1 + if chunk > 2: + Done = True + res = self.Decode() + return res + + +if __name__ == "__main__": + # + # This implements a sample implementation in form of a logger + # it reads the BM869s periodically, determined by the --time setting + # and writes the data from primary and secondary displays into a + # a CSV file (and shows them on the screen) + # + + parser = argparse.ArgumentParser() + + parser.add_argument('--out', '-o', help='output filename (default=BM869s_.csv)', + dest='out_name', action='store', type=str, default='!') + parser.add_argument('--time', '-t', help='interval time in seconds between measurements (def=1.0)', + dest='int_time', action='store', type=float, default=1.0) + + arg = parser.parse_args() + + BM = BM869S() + PRI_READING = 0 + PRI_UNIT = 1 + SEC_READING = 2 + SEC_UNIT = 3 + + if arg.out_name == '!': + out_name = 'BM869s_' + strftime('%Y%m%d%H%M%S', localtime()) + '.csv' + else: + out_name = arg.out_name + + f = open(out_name, 'w') + f.write('Time[S],Main,Main unit,Secondary,Secondary Unit\n') + start = perf_counter() + now = perf_counter() - start + try: + while True: + now = perf_counter() - start + meas = BM.readdata() + s = '{:5.1f},{:s},{:s},{:s},{:s}'.format( + now, + meas[PRI_READING], meas[PRI_UNIT], + meas[SEC_READING], meas[SEC_UNIT]) + + f.write(s + '\n') + print(s) + elapsed = (perf_counter() - start) - now + if elapsed < arg.int_time: + sleep(arg.int_time - elapsed) + except KeyboardInterrupt: + f.close() \ No newline at end of file diff --git a/calibration.data b/calibration.data new file mode 100644 index 0000000..7ce6ea0 --- /dev/null +++ b/calibration.data @@ -0,0 +1 @@ +@@@@A@BCLMBEMI@@@@AFBEEELMK@@@@@@BEE@MNFIIIIHICL@ALJNIIIIIICLBBLJJ@@@@@@@@@@@@@IIIGFFC@BDBLFIIIH@BAOLMEJLIIIIGI@ECL@KGIIIIIHALMDKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ No newline at end of file diff --git a/demo.py b/demo-hp3478a-1.py similarity index 57% rename from demo.py rename to demo-hp3478a-1.py index 0ee07cd..f096fea 100644 --- a/demo.py +++ b/demo-hp3478a-1.py @@ -2,24 +2,28 @@ from hp3478a import hp3478a from time import sleep -port = "/dev/ttyACM0" +port = "COM12" -test = hp3478a(22, port, debug=True) +test = hp3478a(23, port, debug=False) -test.callReset() +#test.callReset() +""" test.setDisplay("ADLERWEB.INFO") print(test.getStatus()) print(test.getDigits(test.status.digits)) print(test.getFunction(test.status.function)) print(test.getRange(test.status.digits)) +""" print(test.setFunction(test.Ω2W)) print(test.setTrigger(test.TRIG_INT)) -print(test.setRange("3M")) -print(test.setDigits(3.5)) +print(test.setRange("300")) +#print(test.setDigits(3.5)) + +#for x in range(6): print(test.getMeasure()) -print(test.setRange("A")) -print(test.setDigits(5)) +#print(test.setRange("A")) +#print(test.setDigits(5)) -test.getCalibration("calibration.data") +#test.getCalibration("calibration.data") diff --git a/demo-hp3478a-2.py b/demo-hp3478a-2.py new file mode 100644 index 0000000..2162ded --- /dev/null +++ b/demo-hp3478a-2.py @@ -0,0 +1,47 @@ +import matplotlib.pyplot as plt +from matplotlib.animation import FuncAnimation +from hp3478a import hp3478a +from time import sleep, time +# Initialize the connection and measurement parameters for the multimeter +port="COM11" +test = hp3478a(23, port, debug=False) + +test.callReset() + +test.setDisplay("ADLERWEB.INFO") +print(test.getStatus()) +print(test.getDigits(test.status.digits)) +print(test.getFunction(test.status.function)) +print(test.getRange(test.status.digits)) +print(test.setFunction(test.Ω2W)) +print(test.setTrigger(test.TRIG_INT)) +print(test.setRange("300")) +#print(test.setDigits(3.5)) +# Lists for storing the time and measurement values +times = [] +measurements = [] +# Function to get data from the multimeter +def get_measurement(): + return float(test.getMeasure()) +# Function to update the graph +def update(frame): + current_time = time() + measurement = get_measurement() + print(measurement) + times.append(current_time) + measurements.append(measurement) + ax.clear() + ax.plot(times, measurements) + ax.set(xlabel='Time (s)', ylabel='Measurement (Ohms)', + title='Multimeter Measurements Over Time') + plt.xticks(rotation=45) + plt.tight_layout() +# Setup the plot +fig, ax = plt.subplots() +ax.set_xlabel('Time (s)') +ax.set_ylabel('Measurement (Ohms)') +ax.set_title('Multimeter Measurements Over Time') +# Animation function calls update every 10000 ms (10 seconds) +ani = FuncAnimation(fig, update, interval=3000) +# Display the plot +plt.show() diff --git a/demo-pm2534-1.py b/demo-pm2534-1.py new file mode 100644 index 0000000..c3d637d --- /dev/null +++ b/demo-pm2534-1.py @@ -0,0 +1,31 @@ +import time + +from pm2534 import pm2534 +from time import sleep + +port = "COM11" + +test = pm2534(22, port, debug=True) +test.callReset() +#just a line added + +#test.setDisplay("ADLERWEB.INFO") + +print(test.getStatus()) +#print(test.getDigits(test.status.digits)) +#print(test.getFunction(test.status.function)) +#print(test.getRange(test.status.digits)) +print(test.setFunction(test.Functions.VDC)) +#print(test.setTrigger(test.Triggers.K)) +print(test.setSpeed(test.Speeds.Speed1)) +#print(test.getDigits()) +print(test.setRange(30E0)) + +for x in range(100): + print(test.getMeasure()) + time.sleep(3) + +#print(test.setRange("A")) +#print(test.setDigits(5)) + +#test.getCalibration("calibration.data") diff --git a/demo-sdm3065x-1.py b/demo-sdm3065x-1.py new file mode 100644 index 0000000..bb6516b --- /dev/null +++ b/demo-sdm3065x-1.py @@ -0,0 +1,12 @@ +import sdm3065x + +dmm = sdm3065x.SDM3065X('10.0.0.114') +dmm.reset() + +# setup: +v = dmm.getVoltageDC('20V', 0.05) # set NPLC to 0.05 which is 1ms in a 50Hz grid +print(v) + +# further readings: +for i in range(10): + print(dmm.read()) \ No newline at end of file diff --git a/hp3478a.py b/hp3478a.py index 45158b7..9ac6c82 100644 --- a/hp3478a.py +++ b/hp3478a.py @@ -26,7 +26,7 @@ class hp3478a(object): Ω4W = 4 ADC = 5 AAC = 6 - EXTΩ = 7 + TEM = 7 TRIG_INT = 1 TRIG_EXT = 2 @@ -144,7 +144,7 @@ class hp3478aStatus: fetched: datetime = None status = hp3478aStatus() - def __init__(self, addr: int, port: str=None, baud: int=921600, timeout: float=0.25, prologixGpib: prologix=None, debug: bool=False): + def __init__(self, addr: int, port: str=None, baud: int=115200, timeout: float=0.25, prologixGpib: prologix=None, debug: bool=False): """ Parameters diff --git a/pm2534.py b/pm2534.py new file mode 100644 index 0000000..a3cea92 --- /dev/null +++ b/pm2534.py @@ -0,0 +1,626 @@ +from numpy.core.numeric import True_ +from numpy.f2py.auxfuncs import throw_error + +from prologix import prologix +from dataclasses import dataclass +from time import sleep +from enum import Enum +import datetime + + + + +class pm2534(object): + """Control Philips/Fluke PM2534 multimeters using a Prologix or a AR488 compatible dongle + + Attributes + ---------- + + addr : int + Address of the targeted device + gpib : prologix/ar488 + Prologix object used to communicate with the prologix dongle + status : pm2534Status + Current device status + """ + + addr: int = None + gpib: prologix = None + + class Functions(Enum): + VDC = 1 + VAC = 2 + RTW = 3 + RFW = 4 + IDC = 5 + IAC = 6 + TDC = 7 + + + class Triggers(Enum): + I = 1 + B = 2 + E = 3 + K = 4 + + class Speeds(Enum): + Speed1 = 1 + Speed2 = 2 + Speed3 = 3 + Speed4 = 4 + + + #functions = ['VDC', 'VAC', 'RTW', 'RFW', 'IDC', 'IAC', 'TDC'] + + + + # TRIG_INT = 1 + # TRIG_EXT = 2 + # TRIG_SIN = 3 + # TRIG_HLD = 4 + # TRIG_FST = 5 + + @dataclass + class pm2534Status: + """Current device status + + range : int + numeric representation of currenly used measurement range: + 1: 30mV DC, 300mV AC, 30Ω, 300mA, Extended Ohms + 2: 300mV DC, 3V AC, 300Ω, 3A + 3: 3V DC, 30V AC, 3kΩ + 4: 30V DC, 300V AC, 30kΩ + 5: 300V DC, 300kΩ + 6: 3MΩ + 7: 30MΩ + see also: getRange + digits : int + numeric representation of selected measurement resolution: + 1: 5½ Digits + 2: 4½ Digits + 3: 3½ Digits + Lower resoluton allows for faster measurements + see also: getDigits + triggerExternal : bool + External trigger enabled + + calRAM : bool + Cal RAM enabled + frontProts : bool + Front/Read switch selected front measurement connectors + True = Front Port + freq50Hz : bool + Device set up for 50Hz operation. False = 60Hz. + autoZero : bool + Auto-Zero is enabled + autoRange : bool + Auto-Range is enabled + triggerInternal : bool + Internal trigger is enabled. False = Single trigger. + + srqPon : bool + Device asserts SRQ on power-on or Test/Reset/SDC + Controlled by rear configuration switch 3 + srqCalFailed : bool + Device asserts SRQ if CAL procedure failes + srqKbd : bool + Device asserts SRQ if keyboar SRQ is pressed + srqHWErr : bool + Device asserts SRQ if a hardware error occurs + srqSyntaxErr : bool + Device asserts SRQ if a syntax error occurs + srqReading : bool + Device asserts SRQ every time a new reading is available + + errADLink: bool + Error while communicating with aDC + errADSelfTest: bool + ADC failed internal self-test + errADSlope: bool + ADC slope error + errROM: bool + ROM self-test failed + errRAM: bool + RAM self-test failed + errChecksum: bool + Self-test detecten an incorrect CAL RAM checksum + Re-Asserted every time you use an affected range afterwards + + dac: int + Raw DAC value + + fetched: datetime + Date and time this status was updated + """ + + + function: int = None + range: int = None + digits: int = None + triggerExternal: bool = None + calRAM: bool = None + frontPorts: bool = None + freq50Hz: bool = None + autoZero: bool = None + autoRange: bool = None + triggerInternal: bool = None + srqPon: bool = None + srqCalFailed: bool = None + srqKbd: bool = None + srqHWErr: bool = None + srqSyntaxErr: bool = None + srqReading: bool = None + errADLink: bool = None + errADSelfTest: bool = None + errADSlope: bool = None + errROM: bool = None + errRAM: bool = None + errChecksum: bool = None + dac: int = None + fetched: datetime = None + + + status = pm2534Status() + + def __init__(self, addr: int, port: str = None, baud: int = 115200, timeout: float = 0.5, + prologixGpib: prologix = None, debug: bool = False): + """ + + Parameters + ---------- + addr : int + Address of the targeted device + port : str, optional + path of the serial device to use. Example: `/dev/ttyACM0` or `COM3` + If set a new prologix instance will be created + Either port or prologixGpib must be given + by default None + baud : int, optional + baudrate used for serial communication + only used when port is given + 921600 should work with most USB dongles + 115200 or 9600 are common for devices using UART in between + by default 921600 + timeout : float, optional + number of seconds to wait at maximum for serial data to arrive + only used when port is given + by default 2.5 seconds + prologixGpib : prologix, optional + Prologix instance to use for communication + Ths may be shared between multiple devices with different addresses + Either port or prologixGpib must be given + by default None + debug : bool, optional + Whether to print verbose status messages and all communication + by default False + """ + if port == None and prologixGpib == None: + print("!! You must supply either a serial port or a prologix object") + + self.addr = addr + + if prologixGpib is None: + self.gpib = prologix(port=port, baud=baud, timeout=timeout, debug=debug) + else: + self.gpib = prologixGpib + + def getMeasure(self) -> float: + """Get last measurement as float + + Returns + ------- + float + last measurement + """ + measurement = self.gpib.cmdPoll(" ", self.addr) + + if measurement is None: + #self.gpib.cmdClr() + return None + + return float(measurement[6:]) + + def getDigits(self, digits: int = None) -> float: + """Get a human readable representation of currently used resolution + + Parameters + ---------- + digits : int, optional + numeric representation to interpret + If None is given the last status reading is used + by default None + + Returns + ------- + """ + status = self.gpib.cmdPoll("DIG ?", self.addr, binary=True) + + return None + + def getFunction(self, function: int = None) -> str: + """Get a human readable representation of currently used measurement function + + Parameters + ---------- + function : int, optional + numeric representation to interpret + If None is given the last status reading is used + by default None + + Returns + ------- + Functions + + """ + if function is None: + function = self.status.function + + if function == 1: + return "VDC" + elif function == 2: + return "VAC" + elif function == 3: + return "RTW" + elif function == 4: + return "RFW" + elif function == 5: + return "IDC" + elif function == 6: + return "IAC" + elif function == 7: + return "TDC" + else: + return None + + def getRange(self, range: int = None, function: int = None, numeric: bool = False): + """Get a human readable representation of currently used measurement range + + Parameters + ---------- + range : int, optional + numeric range representation to interpret + If None is given the last status reading is used + by default None + function : int, optional + numeric function representation to interpret + If None is given the last status reading is used + by default None + numeric : bool, optional + If True return the maximum value as Float instead + of a human readable verison using SI-prefixes + + Returns + ------- + str|float|None + Maximum measurement value in current range + """ + raise Exception("Function not implemented yet!") + + def getStatus(self) -> pm2534Status: + """Read current device status and populate status object + + Returns + ------- + pm2534Status + Updated status object + """ + status = self.gpib.cmdPoll("B", self.addr, binary=True) + + # Update last readout time + self.status.fetched = datetime.datetime.now() + + # Byte 5: RAW DAC value + self.status.dac = status[4] + + # Byte 4: Error Information + self.status.errChecksum = (status[3] & (1 << 0) != 0) + self.status.errRAM = (status[3] & (1 << 1) != 0) + self.status.errROM = (status[3] & (1 << 2) != 0) + self.status.errADSlope = (status[3] & (1 << 3) != 0) + self.status.errADSelfTest = (status[3] & (1 << 4) != 0) + self.status.errADLink = (status[3] & (1 << 5) != 0) + + # Byte 3: Serial Poll Mask + self.status.srqReading = (status[2] & (1 << 0) != 0) + # Bit 1 not used + self.status.srqSyntaxErr = (status[2] & (1 << 2) != 0) + self.status.srqHWErr = (status[2] & (1 << 3) != 0) + self.status.srqKbd = (status[2] & (1 << 4) != 0) + self.status.srqCalFailed = (status[2] & (1 << 5) != 0) + # Bit 6 always zero + self.status.srqPon = (status[2] & (1 << 7) != 0) + + # Byte 2: Status Bits + self.status.triggerInternal = (status[1] & (1 << 0) != 0) + self.status.autoRange = (status[1] & (1 << 1) != 0) + self.status.autoZero = (status[1] & (1 << 2) != 0) + self.status.freq50Hz = (status[1] & (1 << 3) != 0) + self.status.frontPorts = (status[1] & (1 << 4) != 0) + self.status.calRAM = (status[1] & (1 << 5) != 0) + self.status.triggerExternal = (status[1] & (1 << 6) != 0) + + # Byte 1: Function/Range/Digits + sb1 = status[0] + self.status.digits = (sb1 & 0b00000011) + sb1 = sb1 >> 2 + self.status.range = (sb1 & 0b00000111) + sb1 = sb1 >> 3 + self.status.function = (sb1 & 0b00000111) + + return self.status + + def getFrontRear(self) -> bool: + """Get position of Front/Rear switch + + May also be used to easily determine if the device is responding + + Returns + ------- + bool + True -> Front-Port + False -> Rear-Port + None -> Device did not respond + """ + check = self.gpib.cmdPoll("S") + if check == "1": + return True + elif check == "0": + return False + else: + return None + + def getCalibration(self, filename: str = None) -> bytearray: + """Read device calibration data + + Code based on work by + Steve1515 (EEVblog) + fenugrec (EEVblog) + Luke Mester (https://mesterhome.com/) + + Parameters + ---------- + filename : str, optional + filename to save calibration to + file will be overwritten if it exists + by default None + + Returns + ------- + bytearray + Raw calibration data + """ + + self.callReset() + self.setTrigger(self.TRIG_HLD) + + check = self.getFrontRear() + if check is None: + print("Can not connect to instrument") + return None + + self.setDisplay("CAL READ 00%") + + p = 0 + lp = 0 + cdata = b"" + + for dbyte in range(0, 255): + din = self.gpib.cmdPoll(self.gpib.escapeCmd("W" + chr(dbyte)), binary=True) + cdata += din + p = (int)(dbyte / 25.5) + if p != lp: + self.setDisplay("CAL READ " + str(p) + "0%") + lp = p + + self.setDisplay("CAL READ OK") + + if filename is not None: + fp = open("calibration.data", "wb") + for byte in cdata: + fp.write(byte.to_bytes(1, byteorder='big')) + fp.close() + + sleep(1) + self.setDisplay(None) + + self.callReset() + + return cdata + + def setAutoZero(self, autoZero: bool, noUpdate: bool = False) -> bool: + """change Auto-Zero setting + + Parameters + ---------- + autoZero : bool + Whether to enable or disable Auto-Zero + noUpdate : bool, optional + If True do not update status object to verify change was successful + by default False + + Returns + ------- + bool + new status of autoZero; presumed status if `noUpdate` was True + """ + raise Exception("Function not implemented yet!") + + def setDisplay(self, text: str = None, online: bool = True) -> bool: + """Change device display + + Parameters + ---------- + text : str, optional + When text is None or empty device will resume standard display mode + as in show measurements + When text is set it will be displayed on the device + + Only ASCII 32-95 are valid. Function aborts for invalid characters + Must be <= 12 Characters while , and . do not count as character. + consecutive , and . may not work + using . or , after character 12 may not work + Function aborts for too long strings + online : bool, optional + When True the device just shows the text but keeps all functionality online + When False the device will turn off all dedicated annunciators and stop updating + the display once the text was drwn. This will free up ressources and enable + faster measurement speeds. Using False takes about 30mS to complete. If the + updating is stopped for over 10 minutes if will shut down as in blank screen. + by default True + + Returns + ------- + bool + Wheather setting the text worked as expected + """ + raise Exception("Function not implemented yet!") + + def setFunction(self, function: Functions, noUpdate: bool = False) -> bool: + """Change current measurement function + + Parameters + ---------- + function : int + numeric function representation + you may also use the following class constants: + VDC,VAC,Ω2W,Ω4W,ADC,AAC,EXTΩ + noUpdate : bool, optional + If True do not update status object to verify change was successful + by default False + + Returns + ------- + bool + Whether update succeeded or not; not verified if `noUpdate` was True + """ + if function in self.Functions: + self.gpib.cmdWrite("FNC " + str(function.name), self.addr) + """ + if not noUpdate: + self.getStatus() + if self.status.function != function: + print("!! Set failed. Tried to set " + self.getFunction( + function) + " but device returned " + self.getFunction(self.status.function)) + return False + elif self.gpib.debug: + print(".. Changed to function " + self.getFunction(function)) + elif self.gpib.debug: + print(".. Probably changed to function " + self.getFunction(function)) + """ + return True + + print("!! Invalid function") + return False + + def setRange(self, range, noUpdate: bool = False) -> bool: + """Change current measurement range + range : str|float + Range as SI-Value or float + Valid values: + AUTO to enable Auto-Range + + Not all ranges can be used in all measurement functions + VDC: 300E-3,3E0,30E0,300E0 + VAC: 300E-3,3E0,30E0,300E0 + RTW: 3E3,30E3,300E3,3E6,30E6,300E6 + RFW: 3E3,30E3,300E3,3E6 + IDC: 30E-3, 3E0 + IAC: 30E-3, 3E0 + TDC: 0 + noUpdate : bool, optional + If True do not update status object to verify change was successful + by default False + Returns + ------- + bool + Whether update succeeded or not; not verified if `noUpdate` was True + """ + if range=='AUTO': + self.gpib.cmdWrite("RNG " + range, self.addr) + return True + else: + self.gpib.cmdWrite('RNG {:1.3E}'.format(range)) + return True + return False + + def setDigits(self, digits: int, noUpdate: bool = False) -> bool: + """Change current measurement resolution + Parameters + ---------- + digits : float + desired measurement resolution + Valid values: 3,3.5,4,4.5,5,5.5 + noUpdate : bool, optional + If True do not update status object to verify change was successful + by default False + + Returns + ------- + bool + Whether update succeeded or not; not verified if `noUpdate` was True + """ + if digits in range(1,7): + self.gpib.cmdWrite("DIG " + str(digits), self.addr) + return True + return False + + def setTrigger(self, trigger: Triggers, noUpdate: bool = False) -> bool: + """Change current measurement trigger + + Parameters + ---------- + trigger : Triggers + different kinds of Trigger + + noUpdate : bool, optional + If True do not update status object to verify change was successful + by default False + + Returns + ------- + bool + Whether update succeeded or not; not verified if `noUpdate` was True + """ + if trigger in self.Triggers: + self.gpib.cmdWrite("TRG " + str(trigger.name), self.addr) + return True + return False + + def setSpeed(self, speed:Speeds, noUpdate: bool = False) -> bool: + if speed in self.Speeds: + self.gpib.cmdWrite("MSP " + str(speed.value), self.addr) + return True + return False + + def setSRQ(self, srq: int): + """Set Serial Poll Register Mask + + @TODO Not tested and no validations + + Parameters + ---------- + srq : int + Parameter must be two digits exactly. Bits 0-5 of the binary representation + are used to set the mask + """ + raise Exception("Function not implemented yet!") + + def clearSPR(self): + """Clear Serial Poll Register (SPR) + """ + raise Exception("Function not implemented yet!") + + def clearERR(self) -> bytearray: + """Clear Error Registers + + Returns + ------- + bytearray + Error register as octal digits + """ + raise Exception("Function not implemented yet!") + + def callReset(self): + """Reset the device + """ + self.gpib.cmdClr(self.addr) diff --git a/prologix.py b/prologix.py index cd70e77..4736715 100644 --- a/prologix.py +++ b/prologix.py @@ -1,6 +1,7 @@ import serial import datetime import os +import time class prologix(object): """Class for handling prologix protocol based GPIB communication @@ -25,7 +26,7 @@ class prologix(object): timeout: float = 2.5 EOL: str = "\n" - def __init__(self, port: str, baud: int=921600, timeout: float=2.5, debug: bool=False): + def __init__(self, port: str, baud: int=115200, timeout: float=2.5, debug: bool=False): """ Parameters @@ -59,12 +60,13 @@ def __init__(self, port: str, baud: int=921600, timeout: float=2.5, debug: bool= return None #Check for Prologix device + time.sleep(2.5) check = self.cmdPoll("++ver", read=False) if len(check)<=0: print("!! No responding device on port " + port + " found") self.serial = None return None - elif not "Prologix".casefold() in check.casefold(): + elif not ("Prologix".casefold() in check.casefold() or "AR488".casefold() in check.casefold()): print("!! Device on Port " + port + " does not seem to be Prologix compatible") print(check) self.serial = None @@ -98,6 +100,7 @@ def cmdWrite(self, cmd: str, addr: int=None): we're sure noone else is using the bus to reduce bus load. """ self.cmdWrite("++addr " + str(addr), addr=None) + self.serial.read() self.serial.write(str.encode(cmd+self.EOL)) if self.debug: print(">> " + cmd) diff --git a/sdm3065x.py b/sdm3065x.py new file mode 100644 index 0000000..48e24ef --- /dev/null +++ b/sdm3065x.py @@ -0,0 +1,179 @@ +# Python3 Class for controlling a Siglent SDM3065x Bench Multimeter via Ethernet and SCPI + +# MIT License +# Copyright 2018 Karl Zeilhofer, Team14.at + +# 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. + +import decimal +import socket +import sys + +import time + +_timeoutCmd = 10 # seconds +_timeoutQuery = 10 # seconds + + +class SDM3065X: + def __init__(self, ip): + self.ipAddress = ip + self._port = 5024 # TCP port, specific to these devices, the standard would be 5025 + + self.NPLC = ['100', '10', '1', '0.5', '0.05', + '0.005'] # available Integration durations in number of powerline cycles + # set with Voltage:DC:NPLC or similar + self.voltageRangeDC = ['200mV', '2V', '20V', '200V', '1000V', 'AUTO'] # set with VOLTage:DC:RANGe + self.voltageRangeAC = ['200mV', '2V', '20V', '200V', '750V', 'AUTO'] # set with VOLTage:AC:RANGe + self.currentRangeDC = ['200uA', '2mA', '20mA', '200mA', '2A', '10A', 'AUTO'] # set with CURRent:DC:RANGe + self.currentRangeAC = ['200uA', '2mA', '20mA', '200mA', '2A', '10A', 'AUTO'] # set with CURRent:AC:RANGe + + self._PrintDebug = True + + def _abort(self, msg): + # TODO 2: something like this: traceback.print_stack(sys.stdout) + print('Abort for device IP=' + self.ipAddress + ':' + str(self._port)) + print(msg) + print('ERROR') + sys.exit(-1) + + def _debug(self, msg): + if (self._PrintDebug): + print(self.ipAddress + ':' + str(self._port) + ': ' + msg) + + def reset(self): + self._runScpiCmd('*RST') + + def getVoltageDC(self, range='AUTO', integrationNPLC='10'): + self._runScpiCmd('abort') # stop active measurement + self._runScpiCmd('Sense:Function "Voltage:DC"') + + if str(integrationNPLC) not in self.NPLC: + self._abort('invalid integrationNPLC: ' + str(integrationNPLC) + ', use ' + str(self.NPLC)) + self._runScpiCmd('Sense:Voltage:DC:NPLC ' + str(integrationNPLC)) + + if range not in self.voltageRangeDC: + self._abort('invalid range: ' + range + ', use ' + str(self.voltageRangeDC)) + self._runScpiCmd('Sense:Voltage:DC:Range ' + range) + + if range == 'AUTO': + self._runScpiCmd('Sense:Voltage:DC:Range:AUTO ON') + else: + self._runScpiCmd('Sense:Voltage:DC:Range:AUTO OFF') + + self._runScpiCmd('Trigger:Source Bus') + self._runScpiCmd('Sample:Count MAX') # continiuous sampling, max. 600 Mio points + self._runScpiCmd('R?') # clear buffer + self._runScpiCmd('Initiate') # arm the trigger + self._runScpiCmd('*TRG') # send trigger (samples exact one value) + + return self.read() + + def getCurrentDC(self, range='AUTO', integrationNPLC='10'): + self._runScpiCmd('abort') # stop active measurement + self._runScpiCmd('Sense:Function "Current:DC"') + + if str(integrationNPLC) not in self.NPLC: + self._abort('invalid integrationNPLC: ' + str(integrationNPLC) + ', use ' + str(self.NPLC)) + self._runScpiCmd('Sense:Current:DC:NPLC ' + str(integrationNPLC)) + + if range not in self.currentRangeDC: + self._abort('invalid range: ' + range + ', use ' + str(self.currentRangeDC)) + self._runScpiCmd('Sense:Current:DC:Range ' + range) + + if range == 'AUTO': + self._runScpiCmd('Sense:Current:DC:Range:AUTO ON') + else: + self._runScpiCmd('Sense:Current:DC:Range:AUTO OFF') + + self._runScpiCmd('Trigger:Source Bus') + self._runScpiCmd('Sample:Count MAX') # continiuous sampling, max. 600 Mio points + self._runScpiCmd('R?') # clear buffer + self._runScpiCmd('Initiate') # arm the trigger + self._runScpiCmd('*TRG') # send trigger (samples exact one value) + + return self.read() + + # read() + # get latest value, with current settings + # saves a lot of time! + # the DMM aquires in the meanwhile, and read fetches the most recent value + # getVoltageDC() or getCurrentDC() must be called before! + # typical session with netcat: + # >>r? 1 + # #215+1.36239593E+00 + # ^ 2 = number of digits with describe the packet length + def read(self): + ans = '>>' + t0 = time.time() + while ans == '>>': + if (time.time() - t0) > 5: + self._abort('timeout in read(), forgot to start the measurement?') + ans = self._runScpiQuery('R? 1') # get latest data + if ans == '>>': + time.sleep(0.1) + ans = str(ans) + nDigits = int(ans[1]) + ans = ans[(2 + nDigits):] + + return self.str2engNumber(ans) + + def _runScpiCmd(self, cmd, timeout=_timeoutCmd): + self._debug('_runScpiCmd(' + cmd + ')') + + BUFFER_SIZE = 1024 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + data = "" + try: + s.connect((self.ipAddress, self._port)) + data = s.recv(BUFFER_SIZE) # discard welcome message + s.send(bytes(cmd + '\n', 'utf-8')) + data = s.recv(BUFFER_SIZE) + except socket.timeout: + self._abort('timeout on ' + cmd) + + s.close() + retStr = data.decode() + self._debug('>>' + retStr) + return retStr + + def _runScpiQuery(self, query, timeout=_timeoutQuery): + self._debug('_runScpiQuery(' + query + ')') + + BUFFER_SIZE = 1024 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + data = b"" + try: + s.connect((self.ipAddress, self._port)) + data = s.recv(BUFFER_SIZE) # discard welcome message + s.send(bytes(query + '\n', 'utf-8')) + data = s.recv(BUFFER_SIZE) + except socket.timeout: + self._abort('timeout on ' + query) + + s.close() + retStr = data.decode() + retStr = retStr.strip('\r\n') + return retStr + + def str2engNumber(self, str): + x = decimal.Decimal(str) + return x.normalize().to_eng_string() +