Python-Datenlogger für das AD210-Interface
von Ralf Beesner
Elektronik-Labor
Projekte
AVR
Warum Python?
Ich
finde das Programmieren spannend, habe aber einfach kein
Programmiertalent. Daher reicht es nur für kleine, übersichtliche
Mikrocontroller-Projekte.
Vermisst hatte allerdings so manches
mal die Möglichkeit, mit den Mikrocontrollern vom PC bzw. Notebook aus
komfortabel über ein grafisches Interface (GUI) mit Knöpfen, Schaltern
und Eingabefeldern zu kommunizieren - also nicht nur über ein
Terminalprogramm.
Einige Versuche, in IDEs bzw. Toolkits zur
grafischen Programmierung "hineinzuschnuppern", verliefen jedoch im
Sande. Das war mir alles zu kompliziert und/oder hatte mir aufgrund
häufiger Updates und API-Änderungen zu geringe Halbwertszeit.
Durch
Burkhards Artikel über Python und TK auf dem Raspberry Pi bin ich nun
auf Python gestoßen. Python-Code ist relativ gut lesbar, und da der
Python-"Erfinder" Niederländer ist, hat er anscheinend an Leute
gedacht, die nicht vor einer US-Tastatur sitzen und sich nicht beim
Eintippen von geschweiften Klammern die Hände verrenken wollen.
Interpreter-Sprachen
hatte ich immer als lahm und umständlich (wegen des auf dem Zielsystem
erforderlichen Interpreters) abgetan, aber das war Unfug.
Geschwindigkeit ist nur selten ein Problem. Ist Python installiert, hat
man den Interpreter, eine schlichte IDE ("Idle") und das grafische
Toolkit ("TKinter") bereits an Bord, und der Ressourcenbedarf ist -
verglichen mit "modernen" Programmierumgebungen - schon fast
lächerlich. Auf fast allen Linux-Systemen ist Python standardmäßig
vorhanden und für Windows gibt es die üblichen MSI-Installationspakete.
Der
Raspberry Pi war insoweit ein Game Changer - er hat sowohl Linux als
auch Python als "kinderleichte" Alternativen zu Windows und C++ bzw.
Java populär gemacht. Man kann jetzt primär auf Linux setzen und erst
in zweiter Linie an Windows-Nutzer denken.
Zugegeben - Idle und
TKinter sind schon ziemlich angestaubt und haben allerlei Schwächen,
sind aber für Anfänger halbwegs überschaubar. Wenn das Python-Wissen
etwas gefestigter ist, kann man immer noch auf etwas besseres
umschwenken.
Welche Version?
Python
existiert in zwei Hauptversionen, die nicht vollständig kompatibel
sind, aber schon seit etwa 10 Jahren parallel gepflegt werden. Python2
ist "aus entwickelt" - es gibt zwar Updates, aber kaum funktionelle
Änderungen. Ich habe mich daher erst einmal für Python2 entschieden
(Python 2.7 und pyserial 2.7). Python2-Code lässt sich mit wenig
Aufwand in Python3-Code umschreiben.
Unter Python kann man
zahllose Softwarepakete nachinstallieren. Um Komplexität und
Fehlerquellen zu reduzieren, habe ich mich zunächst auf das Paket
"pyserial" beschränkt, das erforderlich ist, wenn man serielle
Schnittstellen nutzen möchte.
Serielle Schnittstellen unter Python
Als
Anfängerprojekt habe ich mir ein GUI für das AK-Modulbus
AD210-Interface zum Ziel gesetzt. Ein Clone des AD210-Interfaces ist
hier beschrieben:
AD210Clone.html
Zum
Ausprobieren des Python-Codes kann man statt des AD210-Nachbaus auch
eine unmodifizierte LP-Mikrocontroller-Hardware verwenden - die
angezeigten Spannungen sind dann jedoch falsch.
Das AD210
antwortet nur, wenn es gefragt wird. Schickt man ihm das Byte "1"
(nicht zu verwechseln mit dem ASCII-Zeichen für "1", das lautet 49),
antwortet es mit dem Byte "100". Zunächst ein kleines
(Textmode-)Programm, das ohne GUI, also auf der Kommandozeile, die
Kennung abfragt:
#!/usr/bin/python
import serial, sys, time
ser = serial.Serial("/dev/ttyUSB1",38400)
time.sleep (0.5) # Warten auf DTR (versorgt AD210 mit Spannung)
ser.flushInput()
print "starting"
while 1:
ser.write(chr(1))
x = ser.read()
char= ord(x)
print char
time.sleep(0.5)
Die
Bezeichnung der seriellen Schnittstelle muss man ggf. ändern. Wenn man
das Programm mit "Control-C“ abbricht, kommt eine Fehlermeldung. Besser
wäre es, wenn man das Programm nicht von außen abwürgen muss, sondern
mit einer Taste, z.B. "q" geordnet beenden kann.
Ein weiteres Konsolenprogramm
Mit
"import sys" werden Betriebssystembefehle importiert, mit dem Befehl
"kbd" kann man Tastatureingaben abfragen. Jedoch gibt es ein Problem:
das Warten auf Tastatureingaben blockiert das Programm und damit die
gesamte serielle Kommunikation. Zudem erwartet "kbd", dass alle
Eingaben mit der Return-Taste abgeschlossen werden.
Die Lösung
besteht darin, die Tastaturabfrage in einen separaten Thread
auszulagern. Er wird durch das Hauptprogramm nachgestartet. Unter
Python sind globale Variablen (die auch außerhalb der jeweiligen
Funktion gelten) verpönt. Sofern mein Python-Einsteigerbuch Recht hat,
sind sie jedoch erforderlich, wenn ein Thread mit seiner übergeordneten
Funktion kommunizieren soll. Puh, Glück gehabt (wenn man viel auf dem
ATtiny13 mit seinen 64 Byte RAM programmiert, gewöhnt man sich an die
Verwendung globaler Variablen, weil man mit lokalen Variablen schnell
rätselhafte Probleme durch Stacküberläufe bekommt).
Im folgenden Programm wird erst einmal die Funktion für die Keyboard-Abfrage ("input_thread") definiert.
Das
Hauptprogramm testet erst einmal, ob die angegebene serielle
Schnittstelle überhaupt vorhanden ist, schickt dann ein Byte "1" raus
und horcht, ob ein Byte "100" als Lebenszeichen des AD210 zurückkommt.
Danach wird die zuvor definierte Keyboard-Funktion als separater Thread gestartet.
Es
folgt die zentrale Programmschleife. Sie läuft innerhalb des "while
(1)" Blocks und schickt zyklisch die Befehlsbytes "58" und " 59"
an das AD210 und gibt dessen Antworten aufbereitet im Textmodus aus.
Ich hatte zunächst diesen Abfrage-Block ausprobiert:
ser.write(chr(58))
# ersten ADC abfragen
time.sleep
(0.002)
# 2 ms Antwortzeit fuer den ATtiny
wait = ser.inWaiting()
if (wait != 0):
Der
erwies sich als nicht sinnvoll, da das AD210 etwas Zeit für die Antwort
braucht. Man muss daher eine kleine Wartezeit von 1-2 ms einlegen,
bevor man auf eingegangene Empfangsbytes prüft. Die Wartezeit ist
jedoch kritisch: wartet man zu lange, geht die Antwort des AD210
verloren, wartet man nicht, ist noch nichts im Speicher und das
Programm geht ebenfalls über die Abfrage hinweg. In beidem Fällen
erscheinen in der Ausgabe unplausible Werte oder die beiden Kanäle
erscheinen vertauscht.
Es erwies es sich als am besten, auf
"ser.inWaiting()" zu verzichten. Jedoch wird das Programm blockiert,
wenn das AD210 aus irgendwelchen Gründen nicht mehr antwortet. Man kann
aber beim Öffnen der seriellen Verbindung einen timeout (z.B. 2s)
angeben, dann läuft das Programm nach Ablauf dieser Zeit weiter und man
kann es zumindest regulär beenden.
Ist die Variable "kbd" nicht
leer, weil man z.B. "q Return" oder "200 Return" eingegeben hat,
wird im ersten Fall das Programm beendet, im zweiten Fall der
PWM-Ausgang auf 200 gesetzt.
Achtung; dieses Programm läuft nur
in der Idle-Konsole, wenn man wiederholt die Return-Taste drückt.
Direkt in einem Linux-Xterm bzw. auf der Windows-Kommandozeile läuft es
aber einwandfrei.
#!/usr/bin/python
# license: wtfpl
# http://www.wtfpl.net/txt/copying/
# AD210 arbeitet nicht mit ASCII-Zeichen , sondern "rohen" Bytes (8 bit)
# 1-byte-Befehle, ein oder zwei Bytes (bytehi, bytelow) als Antwort
# "1" fragt Kennung des AD210 ab
# "58" liest ADC2 (PinB4) aus
# "59" liest ADC2 (PinB4) aus
# "64" + 0 ... 255 setzt PWM-Ausgang (PinB0)
# raw_input
# Bedienung:
# raw_input muss immer mit <return> abgeschlossen werden
# q <return> : Ende
# Zahl 0 ... 255 <return> : PWM-output setzen
import serial, sys, time, thread
def input_thread():
global kbd
kbd = ''
while (1):
kbd =raw_input()
# Hauptprogramm
try:
ser = serial.Serial("/dev/ttyUSB1",38400,timeout=2) # unter Windows: "com1" bis "com255"
time.sleep (0.5) # Warten auf DTR (versorgt AD210 mit Spannung)
ser.flushInput()
except:
print "-----> serielle Schnittstelle nicht vorhanden"
quit()
ser.write(chr(1)) # sendet raw 1
time.sleep (0.01)
if (ser.inWaiting() == 0):
print "-----> AD210 antwortet nicht!"
quit()
x = ord(ser.read()) # 1-byte string, umgewandelt in raw (integer)
if (x == 100): # "Lebenszeichen" des AD210
print "-----> AD210 connected!"
else:
print "-----> No connect"
quit()
thread.start_new_thread(input_thread, ()) # input-thread starten
while 1:
ser.write(chr(58)) # ersten ADC abfragen
x1 = ser.read() # hi_byte
x2 = ser.read() # low_byte
# die 1-byte-strings werden erst in ints umgewandelt, dann addiert,
# dann wieder in string gewandelt
s1 = str(256 * ord(x1) + ord(x2))
ser.write(chr(59)) # zweiten ADC abfragen, bessere Variante
y1 = ser.read()
y2 = ser.read()
s2 = str(256 * ord(y1) + ord(y2))
print "ch1 =", s1 , " ch2 = ", s2 # ausgeben
time.sleep(0.5) # kann bis auf 0 reduziert werden
if (kbd != ""):
if (kbd =="q"):
quit()
try:
pwm = int (kbd)
if (pwm > 255) or (pwm < 0):
print "-----> Zahl zwischen 0 und 255!"
else:
ser.write (chr(64)) # pwm-Wert ankuendigen
ser.write (chr(pwm))
except:
print "-----> Zahl eingeben"
kbd = "" # alten Wert loeschen


Nun endlich ein GUI-Programm!
Zunächst
wird der Thread definiert, der mit dem AD210 kommuniziert
("get_values"). Bevor er in seine Hauptschleife "while (1)" eintritt,
werden einige Anfangswerte definiert und die Funktion "choose_ser()"
aufgerufen.
"Choose_ser" ruft zunächst eine weitere Funktion
"scan_serial" auf und lässt sich von ihr die vorhandenen seriellen
Schnittstellen auflisten. "Scan_serial" versucht - sowohl auf
Windows-Systemen als auch unter Linux - eventuell vorhandene serielle
Schnittstellen zu schließen (den netten Trick habe ich mir aus einem
ELEKTOR-Python-Listing "geborgt"). Die Fehlermeldungen bei nicht
vorhandenen Schnittstellen werden abgefangen (mit "try" - "exept
pass"), die erfolgreichen Versuche werden in die Liste "portnames"
eingetragen. Die Liste wird mit "return portnames" an die aufrufende
Funktion "choose_ser" zurückgegeben und dort unter dem gleichen Namen
(der allerdings nur innerhalb der jeweiligen Funktion gilt und nicht
als globale Variable definiert ist) weiterverarbeitet.
"choose_ser"
sendet dann an die in der Liste enthaltenen Schnittstellen ein Byte "1"
und probiert so, ob sie eine findet, die ein Byte "100" zurückschickt.
Zwar
kann es zu Problemen führen, wenn die Funktion auch serielle
Schnittstellen schließt, an denen unbeteiligte Geräte hängen, aber die
Automatik gefiel mir einfach zu gut gegenüber einer "händischen"
Auswahl der Schnittstelle über ein Dialogfeld.
"choose_ser" gibt
die Variablen "port" und "ser" zurück. "ser" enthält die Parameter der
ausgewählten seriellen Schnittstelle und "port" wird über die Variable
"text8" in das GUI eingeblendet.
Die weiteren Funktionen "get_interval", start_log", "stop_log" und "ende" werden durch das GUI aufgerufen.
Während
die Aktualisierung der Messwerte im GUI so rasch wie möglich erfolgt,
kann man für den Speichervorgang ein beliebiges Intervall > ~10 msec
wählen. Dezimalbrüche müssen mit Punkt statt Komma eingegeben werden.
Den Dateinamen kann man frei wählen; ist keiner eingetragen, wird er
auf "ad210.csv" gesetzt.
Das GUI wird unterhalb von main = Tk()
definiert. Es besteht aus 8 Rahmen, die übereinander angeordnet sind.
Tkinter macht sich seine eigenen Gedanken über die Anordnung der
Elemente. Mit den Parametern side="left"/"right" bzw. "top"/"bottom"
und anchor="w" (für "westlich") kann man die Elemente jedoch halbwegs
an die Plätze manövrieren, an denen man sie haben möchte. Die Größe der
Rahmen stellt Tkinter erst einmal so gering wie möglich ein. Wenn man
etwas "Luft" um die Bedienelemente schaffen möchte, dienen dazu die
Parameter "padx" und "pady". Zu beachten ist, dass unterschiedliche
Standard-Schriftarten die Relation zwischen den Rahmen und Elementen
beeinflussen und die Anordnung verändern können.
Die Namen der
Rahmen sind durchnummeriert und auch die Elemente habe ich mit den
gleichen Nummern versehen, um besser "durchzusteigen". Die Konstruktion
des GUI könnte man kompakter schreiben (z.B. mit Programmschleifen für
wiederkehrende Elemente oder indem man mehrere der Einzelanweisungen in
eine Zeile quetscht), aber für uns Anfänger ist es so sicherlich
übersichtlicher.
Das Hauptprogramm steht ganz am Ende und besteht nur aus zwei Zeilen. Die erste startet den Thread, die zweite das GUI.
Links
neben dem Schieberegler wird die Spannung angezeigt, die sich
einstellt, wenn man das PWM-Signal mit einem Tiefpass glättet (z.B. mit
10 kOhm/1µF).
Abschlussbemerkung
Als
Anfänger gleich nach dem "Legen der ersten Programmier-Eier" sofort los
zu gackern, ist recht zweischneidig, denn man gibt
"verbesserungsfähigen" Programmierstil und umständliche
Herangehensweise an die Leser weiter. Andererseits zeigt es, dass man
sich als Anfänger in Python recht schnell ein GUI "zusammenkleistern"
kann, das eine komfortable Kommunikation mit einem Mikrocontroller
erlaubt.
Hinzu kommt, dass die Einbindung einer seriellen
Schnittstelle wohl eher als Fortgeschrittenen-Thema angesehen wird - in
meinem Python-Einsteiger-Buch wurde es nicht abgehandelt. Daher hoffe
ich, dass der Schaden den Nutzen überwiegt, und jeder, der sich besser
auskennt, ist eingeladen, den Code zu korrigieren und zu verbessern.
Dann aber bitte mit reichlich kommentiertem Quelltext, damit man etwas
davon hat. "Eleganter" Code ist oft schwer nachvollziehbar - jedenfalls
für Anfänger.
Download des Quellcodes: 0517-ad210-python.zip
#!/usr/bin/python
# license: wtfpl
# http://www.wtfpl.net/txt/copying/
import thread
import time
import serial
from Tkinter import *
import tkMessageBox
file_is_open = 0
#----- thread, der unabhaengig von dem GUI laeuft: ------
def get_values():
port, ser = choose_serial()
text8 = port
time_old = time.time()
scale_old=0
pwm = "0.00"
text2.delete(1.0 , 2.0)
text2.insert(INSERT, pwm+"V")
while (1):
## Schieberegler abfragen, PWM erzeugen
scale_new = scale.get() # scale_get liefert integer
if (scale_new != scale_old): # nur senden, wenn Wert geaendert wurde
ser.write(chr(64) + chr(scale_new))
time.sleep(0.001)
scale_old = scale_new
scale_f = "{0:2.2f}".format(scale_new *0.01295)
pwm = str(scale_f)
text2.delete(1.0 , 2.0)
text2.insert(INSERT, pwm+"V")
## ersten ADC abfragen
reference1 = ref1.get()
if (reference1 == "lo"):
ser.write(chr(56))
else:
ser.write(chr(58))
x11 = ser.read()
x12 = ser.read()
# die beiden 1-byte-strings werden erst in ints umgewandelt,
# dann addiert, danach wieder in einen string gewandelt:
res11 = 256 * ord(x11) + ord(x12)
if(reference1 == "lo"):
res12 = res11 * 0.01 # Umrechnung ADC-Werte in Volt
else:
res12 = res11 * 0.03
res12_f = "{0:2.2f}".format(res12) # auf 2 Nachkommastellen formatieren
res13 = str(res12_f)
lb_adc1.delete(1.0 , 2.0)
lb_adc1.insert(INSERT,res13+"V")
## zweiten ADC abfragen
reference2 = "lo"
reference2 = ref2.get()
if (reference2 == "lo"):
ser.write(chr(57))
else:
ser.write(chr(59))
x21 = ser.read()
x22 = ser.read()
res21 = 256 * ord(x21) + ord(x22)
if (reference2 == "lo"):
res22 = res21 * 0.01
else:
res22 = res21 * 0.03
res22_f = "{0:2.2f}".format(res22)
res23 = str(res22_f)
lb_adc2.delete(1.0 , 2.0)
lb_adc2.insert(INSERT,res23 + "V")
## ins File schreiben:
if (file_is_open == 1):
interval = get_interval()
time_new = time.time()
if ((time_new - time_old) >= interval):
time_old = time_new
try:
ltime = time.localtime()
h, m , s = ltime[3:6]
time_string = "{0:02d}:{1:02d}:{2:02d}".format(h,m,s)
logfile.write (time_string + chr(9) + res13 + chr(9) +res23 + "\r")
except:
print "writing failed"
#---- Ende thread -----
def get_interval():
get_interval = entry5.get()
try:
interval= float(get_interval)
except:
interval = 1
return interval
def start_log():
global logfile
global file_is_open
filename = "ad210.csv"
getfilename= entry6.get()
if getfilename != "":
filename = getfilename
logfile = open (filename,"a+") # +a: append to file
file_is_open = 1
print "filename=", filename
def stop_log():
global file_is_open
try:
logfile.close()
file_is_open = 0
except:
pass
#----- AD210 suchen -----
def choose_serial():
speed = "38400"
ad210port =""
portnames = scan_serial()
print "found", portnames
for port in portnames:
try:
ser = serial.Serial(port,speed,timeout=2)
ser.setDTR() # DTR = Stromversorgung fuer AD210
ser.setRTS() # dito
time.sleep(0.2) # Wartezeit auf Stromversorgung (> 0.1s)
ser.flushInput()
ser.flushOutput()
ser.write(chr(1))
time.sleep(0.02)
if (ser.inWaiting() != 0):
x = ord(ser.read()) # 1-byte string wird in int umgewandelt
if (x == 100): # 100: Lebenszeichen des AD210
print "ad210 connected to" , port
text8["text"] = "Port: " + port
ad210port = port
break
# ser.close()
except:
pass
if len(ad210port) == 0:
print "no ad210 found"
tkMessageBox.showerror("Error", "kein AD210 gefunden!")
return ad210port, ser
#----- vorhandene serielle Schnittstellen suchen -----
def scan_serial():
portnames = []
# Windows
for i in range(127):
try:
name = "COM"+str(i)
s = serial.Serial(name )
s.close()
portnames.append (name)
except:
pass
# Linux (feste Schnittstellen)
for i in range(8):
try:
name = "/dev/ttyS"+str(i)
s = serial.Serial(name )
s.close()
portnames.append (name)
except:
pass
# Linux (USB serial)
for i in range(8):
try:
name = "/dev/ttyUSB"+str(i)
s = serial.Serial(name )
s.close()
portnames.append (name)
except:
pass
# Linux (HID serial)
for i in range(8):
try:
name = "/dev/ttyACM"+str(i)
s = serial.Serial(name )
s.close()
portnames.append (name)
except:
pass
if len(portnames) == 0:
print "no serial found"
tkMessageBox.showerror("Error", "Keine serielle Schnittstelle gefunden!")
quit()
return portnames
#----- Programm beenden ------
def ende():
print("Ende !")
try:
ser.write(chr(64)+chr(0))
ser.close()
logfile.close()
thread.stop(get_values, ())
except:
pass
main.destroy()
# ----- GUI-Elemente ---------------
main = Tk()
#frame1
frame1 = (Frame(main))
frame1.pack()
text1 = Label(frame1, text="Steuerprogramm fuer AD210", font="bold")
text1.pack(anchor="w")
#frame2, Schieberegler
frame2 = (Frame(main))
frame2.pack(anchor="w")
label2 = Label(frame2, text="PWM ")
label2.pack(side="left", padx=10, pady=16)
text2 = Text(frame2, bg="orange", width=6, height=1, font=(12))
text2.pack(side="left", pady=10)
scale = Scale( frame2, orient="horizontal",
length=256,resolution=1, to=255) # Laenge Slider auf Bildschirm, Aufloesung, Endwert
scale.pack(side="right", padx=10)
#frame3 , ADC1
frame3 = (Frame(main))
frame3.pack(anchor="w")
label3 = Label(frame3, text="ADC1")
label3.pack(side="left",padx=10)
lb_adc1 = Text(frame3, bg="orange", width=6, height=1, font=(12))
lb_adc1.pack(side="left")
# Auswahlknoepfe Messbereich ADC1
ref1 = StringVar()
ref1.set("lo")
rb3 =Radiobutton(frame3, text="10V", variable=ref1, value= "lo")
rb3.pack(side="left", padx = 10)
rb3 =Radiobutton(frame3, text="30V", variable=ref1, value= "hi")
rb3.pack(side="left")
#frame4 , ADC 2
frame4 = (Frame(main))
frame4.pack(anchor="w")
label4 = Label(frame4, text="ADC2")
label4.pack(side="left", padx=10)
lb_adc2 = Text(frame4, bg="orange", width=6, height=1, font=(12))
lb_adc2.pack(side="left", pady = 10)
# Auswahlknoepfe Messbereich ADC2
ref2 = StringVar()
ref2.set("lo")
rb4 =Radiobutton(frame4, text="10V", variable=ref2, value= "lo")
rb4.pack(side="left",padx = 10)
rb4 =Radiobutton(frame4, text="30V", variable=ref2, value= "hi")
rb4.pack(side="left")
#frame5, Eingabe Intervall
frame5 = (Frame(main))
frame5.pack(anchor = "w")
entry5 = Entry(frame5, width=5,bg="white")
entry5.pack(side="left", padx = 10)
text5 = Label(frame5, text="Intervall der Logfile-Eintraege (in s) ")
text5.pack(side="left")
#frame6, Eingabe Logfile-Name
frame6 = (Frame(main))
frame6.pack(anchor="w")
entry6 = Entry(frame6, width=16,bg="white")
entry6.pack(side="left", padx = 10)
text6 = Label(frame6, text = "Name des Logfiles ")
text6.pack(side="left", pady=10)
#frame7, Start/Stop Log
frame7 = (Frame(main))
frame7.pack(anchor="w")
widget_start = Button(frame7, text="Start Log",command=start_log)
widget_start.pack(side="left", padx = 10)
widget_stop = Button(frame7, text="Stop Log",command=stop_log)
widget_stop.pack(side="left")
#frame8, Quit-Button
text8 = Label(main, text= "")
text8.pack(side="left", padx = 10, pady = 10)
button1 = Button(main, text="Quit",command=ende)
button1.pack(side="right", padx = 10)
# ----- Main Loop---------------
thread.start_new_thread(get_values, ()) # thread fuer Abfrage der seriellen Schnitttstelle
main.mainloop() # ruft GUI auf
