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




Elektronik-Labor   Projekte   AVR