Diesen Beitrag gibt es auch als PDF-Dokument zum Dowlload.
Kennen Sie einen Sensor, der an 4 Ports analoge Spannungen messen, auf 6 Leitungen PWM-Signale absetzen und 18 digitale IO-Leitungen bedienen kann? Und das mit einem einzigen Baustein! Außerdem ist das kleine Ding über I2C zu kontakten. Man kann damit eine "intelligente" Tastatur realisieren, die eine Tastenabfrage mit Timeout bietet und sicher noch eine ganze Menge mehr!
Das gibt es nicht, meinen Sie? Na, dann lade ich Sie herzlich ein, mich auf dieser Tour durch den Blogpost aus der Reihe
MicroPython auf dem ESP32 und ESP8266
zu begleiten. Abbildung 2 und der Untertitel lassen Sie vielleicht schon erahnen, was Sie erwartet.
Abbildung 1: Universal-Sensor-Aktor-Porterweiterung
Arduino goes ESP8266/32 via I2C (Teil 4)
Das kleine Ding rechts unten auf dem Breadboard sieht nicht nur aus wie ein Nano V3, es ist auch einer und genau das ist der Star des heutigen Beitrags, neben dem ESP8266-01 in der linken unteren Ecke das Entwicklungsboards.
In der vorangegangenen Episode hatte ich die Hardware und deren Verknüpfung zum I2C-System behandelt. An den Teilen hat sich bis auf den Zuwachs der 4x4-Matrix-Tastatur, einer LED und dem zugehörigen Widerstand nichts geändert, an der Verdrahtung schon.
Abbildung 2: IO-Ports und PWM-LED-Steuerung
Hardware
1 |
|
1 |
|
1 |
|
2 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
3 |
|
1 |
|
1 |
|
diverse |
|
2 |
passende USB-Kabel |
1 |
Batterie 4,5V oder 5V-Steckernetzteil |
(*) alternative Möglichkeit weiter unten im Text
In der letzten Folge hatte ich schon darauf hingewiesen, dass falls Ihnen das Leben Ihres ESP8266-01 lieb ist, Sie dafür Spannungen von mehr als 3.6V von seinen Tentakeln fernhalten müssen. Das betrifft die Versorgungsspannung aus dem FTDI232-Adapter, aber auch die I2C-Leitungen zum Mikrocontroller. Deshalb befindet sich dort auch ein Level-Shifter als Dolmetscher, der die Signale auf dem Bus zwischen 5V und 3,3V übersetzt. Falls Sie kein solches Modul zur Hand haben, hilft auch ein Aufbau mit diskreten Bauteilen. Den Zusammenhang zeigt das folgende Schaltbild.
Abbildung 3: Level-Shifter mit CMOS-Transistor
Die beiden N-Kanal-MOSFETS 2N7000 sorgen dafür, dass ein 5V-Puls in einen 3,3V-Puls umgewandelt wird und umgekehrt. Andere MOSFETs sind dann auch brauchbar, wenn deren Gate-Threshold-Spannung, wie beim 2N7000 (2,1V), kleiner ist, als die 3,3V der Versorgungsspannung.
Wichtig: der Source-Anschluss des Transistors muss auf der Seite mit der niedrigeren Versorgungsspannung liegen.
Noch einmal zur Wiederholung: Der AVR-Controller ATMega328 auf dem Nano V3 hat drei Gruppen von IO-Anschlüssen, PortB, PortC und PortD. Die Bezeichnungen auf dem Board weichen davon ab. Ich komme von der Assemblerseite und bin daher bei den Bezeichnungen B, C und D daheim. Für uns sind daher jetzt erst einmal die richtigen Zuordnungen wichtig, denn die brauchen wir nachher bei der Programmierung. Wir sprechen hier ganze Ports an und nicht einzelne Leitungen, das hat einige Vorteile.
AVR Port |
PD0 RXD |
PD1 TXD |
PD2 |
PD3 |
PD4 |
PD5 |
PD6 |
PD7 |
Nano V3 |
D0 RXD |
D1 TXD |
D2 |
D3 |
D4 |
D5 |
D6 |
D7 |
AVR Port |
PB0 |
PB1 |
PB2 |
PB3 |
PB4 |
PB5 |
Nano V3 |
D8 |
D9 |
D10 |
D11 |
D12 |
D13 |
AVR Port |
PC0 |
PC1 |
PC2 |
PC3 |
PC4 |
PC5 |
|
|
Nano V3 |
A0 |
A1 |
A2 |
A3 |
A4 |
A5 |
A6* |
A7* |
* Nur TQFP-Variante und nur auch dort nur analoge Eingänge
Jede Portgruppe besitzt drei Register, ein Ausgangsregister genannt PORT, ein Eingangsregister PIN und ein Datenrichtungsregister DDR. Unser Ziel ist es, alle drei Arten in ganzer Breite zu beschreiben und abzufragen. Ebenfalls sollen einzelne, gezielte Bitoperationen möglich sein. Kommandos und Daten wandern dabei über den I2C-Bus, der ESP8266-01 ist der Auftraggeber.
Die Gruppe C tanzt etwas aus der Reihe. Die C-Leitungen dienen neben ihrer Funktion als digitale Ports auch als Eingänge des analogen Mutiplexers, der seinerseits die Signale dem ADC (Analog-Digital-Converter) zuführt. Den Eingang A3 werden wir heute in diesem Sinn einsetzen. A4 und A5, alias PC4 und PC5, stellen den Anschluss zur I2C-Hardware her. Auf dem Nano V3 sind die Funktionen dieser Schnittstelle durch Hardware realisiert.
Damit wir die Steuerung der Ports überprüfen können, schließen wir eine RGB-LED über drei Widerstände an bestimmte IO-Leitungen an. Dafür dienen die Ports PC0 (blau), PC1 (grün) und PC2 (rot).
Die Software
Fürs Flashen und die Programmierung des ESP32:
Thonny oder
Für den Nano V3
arduino_als_slave.ino Zur Kommunikation mit dem ESP8266-01 und zum Abarbeiten von Kommandos
Verwendete Firmware für den ESP8266/ESP32:
Bitte eine Stable-Version aussuchen
Die MicroPython-Programme zum Projekt:
esp_i2c_master.py: Zur Kommunikation mit dem Nano V3
MicroPython - Sprache - Module und Programme
Zur Installation von Thonny finden Sie hier eine ausführliche Anleitung. Darin gibt es auch eine Beschreibung, wie die MicropythonFirmware (Stand 03.02.2022) auf den ESP-Chip gebrannt wird.
MicroPython ist eine Interpretersprache. Der Hauptunterschied zur Arduino-IDE, wo Sie stets und ausschließlich ganze Programme flashen, ist der, dass Sie die MicroPython-Firmware nur einmal zu Beginn auf den ESP32 flashen müssen, damit der Controller MicroPython-Anweisungen versteht. Sie können dazu Thonny, µPyCraft oder esptool.py benutzen. Für Thonny habe ich den Vorgang hier beschrieben.
An dieser Stelle ein paar Takte zum Flashen des ESP8266-01. Der hat nämlich, anders als seine größeren Geschwister, keine Flash-Automatik an Bord. Hier ist Handarbeit gefragt.
Der Flashvorgang gliedert sich in zwei Teile, erstens Flash-Speicher löschen und zweitens Firmware übertragen. Die folgende Liste ist ein Auszug aus der Beschreibung für den Flashvorgang:
- a) In Thonny die Vorbereitungen erledigen
- b) Reset- und Flash-Taste drücken
- c) In Thonny den Flashvorgang starten
- d) Reset-Taste lösen, Flash-Taste halten, bis der Fortschritt angezeigt wird
- e) Flash-Taste lösen
- f) Warten bis erneut der Zugriff auf die COM-Schnittstelle gemeldet wird
- g) Dann erneut die Punkte b) bis f) durchlaufen und
- h) abschließend das Installer-Fenster schließen und die Options mit OK beenden
Sobald die Firmware geflasht ist, können Sie sich zwanglos mit Ihrem Controller im Zwiegespräch unterhalten, einzelne Befehle testen und sofort die Antwort sehen, ohne vorher ein ganzes Programm kompilieren und übertragen zu müssen. Genau das stört mich nämlich an der Arduino-IDE. Man spart einfach enorm Zeit, wenn man einfache Tests der Syntax und der Hardware bis hin zum Ausprobieren und Verfeinern von Funktionen und ganzen Programmteilen über die Kommandozeile vorab prüfen kann, bevor man ein Programm daraus strickt. Zu diesem Zweck erstelle ich auch gerne immer wieder kleine Testprogramme. Als eine Art Makro fassen sie wiederkehrende Befehle zusammen. Aus solchen Programmfragmenten entwickeln sich dann mitunter ganze Anwendungen.
Autostart
Soll das Programm autonom mit dem Einschalten des Controllers starten, kopieren Sie den Programmtext in eine neu angelegte Blankodatei. Speichern Sie diese Datei unter boot.py im Workspace ab und laden Sie sie zum ESP-Chip hoch. Beim nächsten Reset oder Einschalten startet das Programm automatisch.
Programme testen
Manuell werden Programme aus dem aktuellen Editorfenster in der Thonny-IDE über die Taste F5 gestartet. Das geht schneller als der Mausklick auf den Startbutton, oder über das Menü Run. Lediglich die im Programm verwendeten Module müssen sich im Flash des ESP32 befinden.
Zwischendurch doch mal wieder Arduino-IDE?
Sollten Sie den Controller später wieder zusammen mit der Arduino-IDE verwenden wollen, flashen Sie das Programm einfach in gewohnter Weise. Allerdings hat der ESP32/ESP8266 dann vergessen, dass er jemals MicroPython gesprochen hat. Umgekehrt kann jeder Espressif-Chip, der ein kompiliertes Programm aus der Arduino-IDE oder die AT-Firmware oder LUA oder … enthält, problemlos mit der MicroPython-Firmware versehen werden. Der Vorgang ist immer so, wie hier beschrieben.
Für den Nano V3 brauchen wir natürlich die Arduino-IDE. Hier kommt eine Kurzanleitung für die Installation derselben.
Einrichten der Arduino-IDE
Falls Sie noch nicht mit der Arduino-IDE gearbeitet haben und sich dieses Werkzeug noch nicht auf Ihrem Rechner befindet, folgen Sie bitte dieser Kurzanleitung.
Beginnen Sie mit dem Download der Installationsdatei über diesen Link.
Abbildung 4: Arduino-IDE herunterladen
Klicken Sie die Version, die Ihrem Betriebssystem entspricht und speichern Sie die Datei in einem Verzeichnis Ihrer Wahl.
Abbildung 5: Installationsdatei speichern
Starten Sie die Installationsdatei und folgen Sie der Benutzerführung. Wir brauchen für dieses Projekt keine externen Libraries für den Arduino und sind hiermit sofort startklar.
In beiden Softwareteilen sind die einzelnen Aufgaben in Funktionen ausgelagert. Die Hauptschleife bleibt dadurch übersichtlicher und das gesamte Programm ist leichter zu pflegen. Beginnen wir mit dem MicroPython-Programm für den ESP8266-01 und besprechen die Änderungen, die seit der Vorversion stattgefunden haben.
Der ESP8266-01 mit den Änderungen und Erweiterungen
Als Erstes sollten wir nach dem Flashen der Firmware dem ESP8266 abgewöhnen, nach einem Accesspoint zu suchen, weil das nach meinen Erfahrungen verschiedentlich zu merkwürdig eigensinnigem Verhalten des Moduls führt.
Auf der Kommandozeile von Thonny geben wir folgenden Befehl und dann "d" für disable ein. Diese Aktion sollte jedes Mal (nur einmal) nach dem Flashen der Firmware erfolgen.
>>> import webrepl_setup
WebREPL daemon auto-start status: disabled
Would you like to (E)nable or (D)isable it running on boot?
(Empty line to quit)
> d
No further action required
In der vorigen Folge hatte ich kurz Gedanken zu der Art vorgestellt, wie ein Programm gestaltet sein sollte. Dass ein Entwurf, in welchem alle Strukturen und Befehle in der Hauptschleife untergebracht werden, sehr unübersichtlich und daher schwer zu lesen und zu pflegen ist, leuchtet ein.
Ebenso unübersichtlich ist eine ellenlange Liste von Sprüngen aus der Main-Loop zur ebenso langen Folge von Mini-Prozeduren, welche einzelne Jobs erledigen. Jobs zusammenzufassen und dafür in den Prozeduren zu verzweigen, um alle Fälle abzuarbeiten ist ebenfalls kontraproduktiv, weil das die Prozeduren aufbläht. Lange Parameterlisten in den Aufrufen und schwer zu pflegende Funktionskörper sind also auch nicht das Gelbe vom Ei.
Für dieses Projekt habe ich mich bei zunehmender Komplexität der Aufgaben bemüht, einen Kompromiss zu suchen. Und ich meine, eine brauchbare Lösung gefunden zu haben, die noch dazu die Assemblerschreibweise beim Portzugriff widerspiegelt. Assemblerbefehle mit Register- oder Speicherzugriffen werden ebenso gelesen, wie Zuweisungen in den Hochsprachen, von rechts nach links. Aber sehen Sie selbst.
Die Funktionen für den portweiten Zugriff auf den IO-Bereich habe ich in jeweils einer einzigen Methode vereint. Methode, nicht Prozedur oder Funktion? Pardon, ja, beziehungsweise nein. Ich habe nämlich alle Funktionen und Konstanten aus der letzten Folge inzwischen in ein Modul mit dem Namen arduino_i2c.py verpackt. Und im Zusammenhang mit der darin befindlichen Klasse ARDUINO, werden Funktionen als Methoden bezeichnet. Die Klasse erlaubt es mir, Werkzeuge in beliebig vielen Projekten einzusetzen. Trotzdem muss ich die Datei nur an einer Stelle pflegen.
Die Syntax (aka Schreibweise) der Methodenaufrufe lehnt sich also an die Assemblerschreibweise an.
Assembler:
out PORTC, 0x07 # schreibe 0x07 in das PORT-Register der Gruppe PortC
Methode aus der Klasse ARDUINO
ARDUINO.writeIO(PORT,C,0x07)
Und was der Nano V3 nach dem Empfang des Kommandos tut, sieht so aus:
PORTC=0x07
Sie erkennen sicher den Vorteil dieses Zugriffs gegenüber der üblichen Vorgehensweise, einzelne Pins separat zu bedienen. PORTC=0x07 ist übrigens ein Befehl, der standardmäßig in der Arduino-IDE verfügbar ist, ohne Import irgendwelcher Libraries. Aber das finde ich nur leider nicht direkt in der Sprachreferenz der Arduino-Crew. Seltsam ist nur, dass das offiziell verschleiert wird. Mecker beiseite, was gibt es Neues?
Wie entsteht aus einer lockeren Folge von Konstanten-Definitionen und Funktionen ein Modul mit einer Klasse?
Die Konstanten haben Zuwachs bei den Kommandos und bei der Datenrichtung bekommen. Die Portgruppe wurde um die DDRs abgespeckt. Zum Vergleich bitte ich, die Datei esp_i2c_master.py herunterzuladen. In U0 steht der Wert der Versorgungsspannung am 5V-Pin des Nano V3, der mit einem DVM (aka Digitales-Volt-Meter) bestimmt wird und in der Regel ca. 5V betragen sollte
from time import sleep
HWADR=const(0x24)
# Portgruppen
B=const(0); C=const(1); D=const(2)
# Kommandos
WRITEPORT=const(0)
WRITEDDR =const(1)
READPORT =const(2)
READDDR =const(3)
GETKEY =const(4)
ANALOGOUT=const(5)
ANALOGIN =const(6)
# Daten-Richtung
PIN =const(0)
PORT=const(1)
DDR =const(2)
# Betriebsspannung des Arduino
U0 =4.8
Als Nächstes folgen die Klassen-Definition und die Codierung des Konstruktors __init__(). Im Programm wird er später über den Namen der Klasse aufgerufen. Der Konstruktor ist die Routine, die ein Objekt, auch Instanz genannt, dieser Klasse erzeugt. Hier werden Anfangswerte gesetzt und Instanzvariablen erzeugt, die von den Methoden (=Funktionen) der Klasse genutzt werden können. Unsere Klasse heißt also ARDUINO. Ähnlich wie bei der while-Schleife oder der Methoden-Deklaration, wird nach der Deklaration der Klasse eine Stufe eingerückt.
class ARDUINO:
def __init__(self,i2c,hwadr=None,u0=None):
self.i2c=i2c
if u0 is not None:
self.U0 = u0
else:
self.U0 = U0
if hwadr is None:
try:
self.hwadr==i2c.scan()[0]
except:
self.hwadr=HWADR
else:
self.hwadr=hwadr
print("Constructor of Arduino-Interface")
print("Arduino @ {:#x}".format(self.hwadr))
Bei der Instanziierung eines ARDUINO-Objekts wird das I2C-Objekt, welches im Hauptprogramm zu erstellen ist, übergeben. Beim Aufruf des Konstruktors kann die Hardware-Adresse des Arduino-Slaves übergeben werden. Wird der Parameter hwadr beim Aufruf nicht angegeben, dann sucht i2c.scan() nach dem Ziel oder es wird der Default-Wert 0x24 verwendet, wenn der Scan fehlschlägt. Das behandeln wir mit try – except. In ähnlicher Weise wird der Wert für die Betriebsspannung des Nano V3 gesetzt. Die Ergebnisse werden uns durch die print-Befehle mitgeteilt.
Sicher ist Ihnen der Parameter self in der Parameter-Liste aufgefallen. Der ist so zu verstehen, dass damit eine Referenz (aka Bezug, Adressierung) auf das erzeugte Objekt übergeben wird. Alle Variablen und Methoden innerhalb der erzeugten Klasseninstanz erhalten damit eine Bindung und Kapselung auf dieses Objekt. Das grenzt sie von etwaigen weiteren Instanzen derselben Klasse ab.
writeReg() und readReg() heißen jetzt also Methoden. Bei beiden bleibt der Code bis auf das zusätzliche self unverändert. Durch den Bezug auf das Objekt selbst durch self, wird die i2c-Instanz an beliebigen Stellen in den Methoden der Klasse verfügbar, ohne dass sie in einer Parameterliste übergeben werden muss.
def writeReg(self,command,port,value):
buf=bytearray(3)
buf[0]=command
buf[1]=port
buf[2]=value
written=self.i2c.writeto(self.hwadr,buf)
return written
def readReg(self,command,port,direction=PIN):
buf=bytearray(3)
buf[0]=command
buf[1]=port
buf[2]=direction
written=self.i2c.writeto(self.hwadr,buf)
sleep(0.1)
return self.i2c.readfrom(self.hwadr,1)
Wir können die beiden Methoden als Bindeglied zwischen der I2C-Schnittstelle mit ihren Methoden und den Methoden unserer Anwendung werten. Beide Methoden arbeiten bei der Übertragung mit Datenstrukturen, die auf dem Bytes-Protokoll aufsetzen, nehmen aber Integerwerte an, die sie für die Sendung in ein Bytearray umsetzen.
Interessant wird es jetzt bei den Methoden für den Zugriff auf die Ports.
def writeIO(self,reg,port,value):
assert port in range(3)
assert value in range(256)
assert reg in [PORT,DDR]
try:
if reg==PORT:
written=self.writeReg(WRITEPORT,port,value)
else:
written=self.writeReg(WRITEDDR,port,value)
except:
pass
return written
Die Methode writeIO() fasst die Schreibbefehle auf Port- und DDR-Register zusammen. Im Vergleich zu den Vorgängerversionen, die noch zwischen PORT und DDR unterschieden haben, wurde Speicherplatz gespart, weil weniger Programmtext anfällt. Erkauft wurde das durch den Parameter reg, der zusätzlich in die Liste aufgenommen wurde. Das ist gut vertretbar, weil damit einige Programmzeilen gespart werden konnten. Im gleichen Zug wurde deren Plausibilitätsprüfung vereinfacht. Die try-except-Struktur wurde nur geringfügig um die if-Struktur ausgeweitet, die jetzt PORTs und DDRs bedient. Dennoch erspart dieses Vorgehen die Definition einer kompletten zweiten Routine.
Ähnlich verhält es sich mit den Methoden readIO(), setBit() und getBit().
def readIO(self,reg,port):
assert port in range(3)
assert reg in range(3)
inhalt=None
try:
if reg==PIN:
inhalt=self.readReg(READPIN,port,PIN)
elif reg==PORT:
inhalt=self.readReg(READPORT,port,PORT)
else:
inhalt=self.readReg(READDDR,port)
except:
pass
return inhalt
Die Methode setBit() benötigt neben self vier Argumente. Weil sie nicht nur Bits auf 1, sondern auch auf 0 setzen kann, muss außer der Bitposition auch dieser Wert angegeben werden. Durch die Verwendung des Parameters reg muss innerhalb setBit() nicht mehr zwischen PORT und DDR unterschieden werden. Das überlassen wir den Methoden readIO() und writeIO(). Der Programmtext wird dadurch von 15 auf 10 Zeilen verkürzt.
def setBit(self,reg,port,pos,val):
assert val in range(2)
assert reg in [PORT,DDR]
assert port in range(3)
cont=self.readIO(reg,port)[0]
if val==1:
cont |= 1<<pos
else:
cont &= 255-(1<<pos)
self.writeIO(reg,port,cont)
Auch getBit() wurde verbessert. Statt mit 8 Zeilen nur PORT und DDR zu bedienen, schaffen es die verbliebenen 6 Zeilen auch noch PIN mit einzubeziehen.
def getBit(self,reg,port,pos):
assert reg in [PIN,PORT,DDR]
assert port in range(3)
cont=self.readIO(reg,port)[0]
cont &= 1<<pos
return cont>>pos
Soviel zum Umbau des bestehenden Programms zu Modul und Klasse. Es gibt aber auch noch einige Erweiterungen. Da sind zum Beispiel die Methoden voltage(), readAnalog() und writeAnalog.
voltage() dient dazu, den mit einem Voltmeter gemessenen Wert der Betriebsspannung des AVR dem Programm mitzuteilen. Er ist wichtig für die korrekte Berechnung der Spannungen, die mit den analogen Eingängen erfasst werden. Ohne Argument aufgerufen, gibt die Methode den Wert im Speicher zurück. Wird beim Aufruf ein Spannungswert übergeben, schreibt ihn die Methode in die Instanz-Variable U0. Beispiel:
>>> a.voltage()
4.73
def voltage(self,value=None):
if value is not None:
self.U0=value
else:
return self.U0
Der Name von readAnalog() ist dem Kommando analogRead() der Arduino-IDE nachempfunden, wurde aber bewusst verändert, damit die Umgebung eindeutig zugeordnet werden kann. In line wird die Nummer des Analogeingangs am Nano V3 angegeben. Hat der optionale Parameter voltage den Wert True, dann wird ein Spannungswert in Volt zurückgegeben. True ist der Defaultwert, mit dem gearbeitet wird, wenn man das Argument beim Aufruf weglässt. Der Parameter digits ist ebenso optional und standardmäßig mit 2 Nachkommstellen vorbelegt, wenn dieser Parameter beim Aufruf nicht angegeben wird.
def readAnalog(self,line,voltage=True,digits=2):
buf=bytearray(2)
buf[0]=ANALOGIN
buf[1]=line
written=self.i2c.writeto(self.hwadr,buf)
sleep(0.1)
low,high=self.i2c.readfrom(self.hwadr,2)
counts=low+256*high
if voltage:
pot=10 ** digits
return int(self.U0*counts/1023*pot) / pot
else:
return counts
Mit der Übermittlung von Aufträgen an den Nano V3 sind wir mittlerweile schon vertraut. Wir füllen den Sendepuffer mit den Werten der Argumente, schicken die Post ab und holen nach einer kurzen Verschnaufpause den Messwert ab. Der 16-Bit-Wert kommt in Form von zwei Bytes im Format Little-Endian. Das heißt, das niederwertige Byte LSB kommt vor dem höherwertigen MSB an. In counts werden die beiden wieder zusammengesetzt.
Falls voltage True ist, berechnen wir aus dem Zählwert des ADC und dem Wert der Betriebsspannung in self.U0 den Wert der gemessenen Spannung, der dann final auf die gewünschte Anzahl von Nachkommastellen (ab-)gerundet wird. Ist voltage False, wird einfach nur der Zählwert des ADC zurückgegeben.
Eine programmierte, glatte Gleichspannung kann der ATMega328 nicht abgeben, denn er besitzt keinen DAC (Digital-Analog-Converter). Aber er kann über die PWM-Kanäle 3, 5, 6, 9, 10 und 11 Rechteck-Pulse verschiedener Länge aber fester Frequenz abgeben. Das läuft unter dem Namen Puls-Weiten-Modulation. LEDs oder Gleichstrommotoren, die man mit so einem Signal ansteuert, reagieren mit unterschiedlich heller Lichtabgabe oder veränderter Drehzahl. Beispiele folgen später.
def writeAnalog(self, line, value):
buf=bytearray(4)
buf[0]=ANALOGOUT
buf[1]=line
buf[2]=value & 0xFF
buf[3]=(value >> 8) & 0xFF
written=self.i2c.writeto(self.hwadr,buf)
return written
Die Werte für value können standardmäßig für alle Kanäle von 0 bis 255 gehen. Man kann aber diese Auflösung steigern, wenn man an den Timer-Registern des ATmega328 ein bisschen rumfummelt. Es lässt sich dann auch die Taktfrequenz des PWM-Signals verändern. Das erlauben leider nur für die PWM-Ausgänge D9 und D10 die Methoden resolution() und prescaler().
Die Auflösung an D9 und D10 kann 8, 9, oder 10 Bit betragen. Diese Werte übergeben wir dem Methodenaufruf. Die Methode prüft zunächst die Einhaltung des Bereichs, füttert dann das Array mit dem Befehl RESOLV und dem Auflösungs-Wert in val und schickt das Array an den Nano V3. Was der damit tut, das schauen wir uns später an.
def resolution(self, val):
assert val in range(8,11)
buf=bytearray(2)
buf[0]=RESOLV
buf[1]=val
written=self.i2c.writeto(self.hwadr,buf)
return written
Ähnlich verhält sich die Methode prescaler(). Sie nimmt einen der Werte aus der Tabelle und sendet den Index dieses Werts. Ungültige Parameterwerte werden aussortiert.
def prescaler(self,val):
werte=[0,1,8,64,256,1024]
try:
psFaktor=werte.index(val)
buf=bytearray(2)
buf[0]=PRESCALER
buf[1]=psFaktor
written=self.i2c.writeto(self.hwadr,buf)
return written
except:
print("falscher Wert")
return None
Den Zusammenhang der PWM-Frequenz mit den anderen Parametern Taktfrequenz des ATMega328, Auflösung und Vorteiler des Timers 1 kann man der folgenden Abbildung 6 entnehmen. Das entsprechende Kalkulationsblatt liegt zum Download bereit.
Abbildung 6: PWM-Frequenzen beim AVR
Durch Messungen am DSO (Digitales Speicher-Oszilloskop) und das Auslesen der Register TCCR1A und TTTR1B des Timers 1 konnte ich verblüfft feststellen, dass mein Nano V3 mit 8HMz getaktet wird statt, mit 16MHz.
Kommen wir zur letzten neuen Methode:
def getKey(self,timeout=0):
buf=bytearray(3)
buf[0]=GETKEY
buf[1]=timeout
written=self.i2c.writeto(self.hwadr,buf)
key=0x7f
while key==0x7f:
key=self.i2c.readfrom(self.hwadr,1)[0]
return chr(key)
getKey() gibt mit dem Kommando GETKEY und dem timeout-Wert in Sekunden das Einlesen einer Taste von einem 4x4-Matrixblock am Nano V3 in Auftrag. Wird in der Einlese-Schleife der Wert 0x7F gelesen, dann bedeutet das, dass innerhalb des Zeitfensters noch keine Taste gedrückt wurde. Der Wert 0xFF würde uns sagen, dass die Wartezeit überschritten wurde. Der Nano V3 weiß, welchen ASCII-Code er uns beim Drücken einer Taste schicken muss. Die Tastenwerte sind in der Regel kleiner als 127, aber letztlich (fast) frei belegbar.
Damit wird es Zeit, uns mit dem Sketch für den Nano V3 zu beschäftigen.
AVR – der multifunktionale Sensor.
Wenn wir schon dabei sind, bleiben wir doch gleich bei der Tastenabfrage. Die MicroPython Methode getKey() greift zweimal auf den AVR zu. Ein Schreibbefehl gibt die Abfrage einer Taste in Auftrag. Weil MicroPython aber nicht wissen kann, wann wir geneigt sind, eine Taste zu drücken, wird der Lesebefehl nicht nach einer bestimmten Verzögerung, wie beim Einlesen von Port-Registern erteilt, sondern mit ständiger Wiederholung in einer Schleife. Was tut der AVR zwischendurch?
Die Funktion key() erledigt die eigentliche Abfrage. Weil wir mit ganzen Ports und nicht nur mit Einzelleitungen arbeiten können, nutzen wir die Reversal-Technik. Deren Ablauf stelle ich jetzt vor.
Die Tastatur besteht aus zwei mal 4 Leitungen. Die Zeilen R0 bis R3 kreuzen sich mit den Spalten S0 bis S3. Durch Drücken an den Kreuzungspunkten wird ein Kontakt hergestellt.
Abbildung 7: Tastenmatrix negativ
Wir verbinden jetzt die Reihen mit den Portleitungen D4 bis D7 und die Spalten mit den Portleitungen B0 (D8) bis B3 (D11). Einen vergrößerten Ausschnitt aus dem Schaltbild aus Abbildung 8 gibt es hier.
Abbildung 8: I2C-Tastatur
Jetzt zum Programm. Wir definieren die Maske mask, deren High-Nibbel lauter Einsen und deren Low-Nibbel (Nibbel = Halbbyte) lauter Nullen enthält:
char key(bool ascii) {
uint8_t const mask = 0b11110000; // D4..7 ist anfangs Output
uint8_t const reverse = byte(~mask);
uint8_t const keynumber[]={0x7D,0xEE,0xED,0xEB,
0xDE,0xDD,0xDB,0xBE,
0xBD,0xBB,0xE7,0xD7,
0xB7,0x77,0x7E,0x7B};
char const asciicode[]={'0','1','2','3','4','5','6','7','8','9','A','B','C','\x0A','*','+'};
uint8_t iod;
uint8_t code;
uint8_t number;
code=0xFF;
number=0xFF;
DDRD |= mask; // Zeilen Output D4..7
DDRB &= mask; // Spalten Input B0..3
PORTB |= reverse; // mit Pullup
PORTD &= reverse; // Zeilen auf 0; D4..7
delayMicroseconds(50);
Mit DDRD oderiert machen die Einsen PORTD.4 bis PORTD.7 zu Ausgängen. Dieselbe Maske mit DDRB undiert, lässt PINPB.0 bis PINPB.3 als Eingänge wirken. Die Variable reverse enthält den negierten Wert von mask, Einsen und Nullen haben also den Platz getauscht. Wenn wir das Low-Nibbel von PORTB jetzt mit Einsen beschreiben, schalten wir die zugehörigen Pullup-Widerstände der Eingangsleitungen ein. Das spart uns externe Widerstände an den Portleitungen. Jetzt setzen wir das High-Nibbel von PORTD durch Undieren mit reverse auf 0000. Wird die Taste 6 gedrückt, dann landet die 0 von R1 über S2 am Pin PINB.2. Die anderen Pins werden durch Pullups auf 1 gezogen. Kurze Wartezeit.
iod=PINB & reverse;// Spalten einlesen B0..3 => Lownibble
DDRD &= reverse; // Zeilen Input D4..7
PORTD |= mask; // Pullup an D4..7
DDRB |= reverse; // Spalten Ausgang B0..3
uint8_t out = PORTB;
out &= mask; // B0..3 auf 0
out |= iod;
PORTB =out; // Lownibble auf Port
delayMicroseconds(50);
Den Zustand am Eingang B lesen wir in die Variable iod ein und isolieren die unteren 4 Bits, indem wir das High-Nibbel auf 0 undieren. Nun drehen wir den Spieß um. Das Low-Nibbel von PORTB wird zum Ausgang, das High-Nibbel von Port D zum Eingang mit aktivierten Pullups.
PIND wird eingelesen, das Low-Nibbel gelöscht, dann durch das Low-Nibbel von iod ersetzt und das ganze Byte wieder in den PORTB oderiert. S2 ist jetzt auf 0 gesetzt, die restlichen Spalten sind auf 1 und das High-Nibbel von PORTB wurde nicht verändert, man weiß ja nie was diese Pins grade steuern. Kurzes Warten.
code = (PIND & mask)>>4; // Highnibble isolieren
code = code | (iod<<4); // Tastencode kombinieren
for (uint8_t i=0;i<16;i++){
if (code == keynumber[i]){
number=i;
break;
}
}
Weil ich die Tabelle keynumber[ ] berreits vorab definiert hatte und nicht mehr ändern wollte, tausche ich High-Nibbel und Low-Nibbel kurzerhand aus. Ich hätte auch die Beschaltung ändern können, aber dann hätte die Zeichnung in Abbildung 8 nicht mehr gestimmt. Jedenfalls enthält number jetzt den Tastencode 0xDB.
Mit der for-Schleife ermittle ich den Index des Tastencodes in der Tabelle keynumber[ ]. Das geht in der Arduino-IDE leider nicht so komfortabel wie in MicroPython, dort würde das ein Befehl machen: keynumber.index(code).
char taste = '\x7F'; // 0x7F --> keine Taste
if (number < 16) {
taste=asciicode[number];
}
if (ascii) {
return taste;
}
else {
return number;
}
}
Schließlich belegen wir taste mit 0x7F ("unberührt") vor. Ist der Tastencode in number kleiner 16, dann wurde eine Taste gedrückt und ich ermittle schon einmal deren ASCII-Code mit Hilfe der Tabelle asciicode[ ].
Abschließend gebe ich in Abhängigkeit von der boolschen Variablen ascii entweder den ASCII-Code oder die Tastennummer (0..15) zurück. Wurde keine Taste gedrückt, dann enthalten taste und number beide den Wert 0xFF.
Die Funktion wait() bringt uns die Funktionalität des Wartens auf eine Taste mit oder ohne Timeout.
void wait(uint8_t timeout){
//inhalt=0x7F; // bisher keine Taste
tosend[0]=0x7F;
cnt=1;
long jetzt=millis();
long ende=jetzt+timeout*1000;
while (true) {
uint8_t k=key(true);
if (k < 0x7F) {
// Taste abzuholen
tosend[0]=k;
return;
} // if
if (timeout==0){
ende=millis()+10000;
} // if
if (millis()>ende) {
// Timeout
tosend[0]=0xFF;
return;
} // if
} // while 1
}
Der Sendepuffer wird mit 0x7F (noch kein Tastendruck) vorbelegt. Wir ermitteln die Startzeit und berechnen die Schlusszeit in Millisekunden.
In der while-Schleife schauen wir nach, ob eine Taste gedrückt wurde. Ist der von key() zurückgegebene Wert kleiner 127, dann schreiben wir ihn in den Sendepuffer. Der Timeout ist ausgeschaltet, wenn für dessen Wert eine 0 an wait() übergeben wurde. Wir erhöhen dann die Ablaufzeit vom aktuellen Stand in millis() um 10 Sekunden. Dadurch wird die nachfolgende Abbruchbedingung niemals wahr. Andernfalls wird millis() nach der vorgegebenen Zeit einen höheren Wert als ende liefern und wir schreiben den Code einer nicht gedrückten Taste, also 0xFF in den Sendepuffer, was für Timeout steht.
Nach den beiden Mammut-Funktionen kommen jetzt noch die 4 mickrigen Dinger für die Analogwertbehandlung.
analogInput() ist das Backend der MicroPython-Methode readAnalog(). Die Funktion nimmt die Nummer des analogen Eingangs und sendet den ausgelesenen 16-Bit-Wert in Form von zwei Bytes zurück. Die Byteanordnung ist Little-Endian, also Low-Byte zuerst.
void analogInput(uint8_t line){
cnt=1;
uint16_t counts=analogRead(line);
tosend[0]=counts & 0xFF;
tosend[1]=counts>>8;
cnt=2;
}
analogOutput() nimmt drei Parameter, die Nummer des PWM-Pins in AVR-Notation sowie Low- und High-Byte des PWM-Werts. Der Wert wird wieder zusammengesetzt und dann ganz normal mit dem analogWrite()-Befehl ausgegeben.
void analogOutput(uint8_t line, uint8_t low, uint8_t high) {
uint16_t value = low+(high<<8);
analogWrite(line,value);
}
Der ATMega328 besitzt drei Hardwaretimer. TCNT0 und TCNT2 sind 8-Bit-Zähler, TCNT1 ist ein 16-Bit-Zähler. Die Counter TCNT0 und TCNT1 bekommen ihr Taktsignal von einem Prescaler (aka Vorteiler), der vom IO-Systemtakt abgeleitet wird. Die Bits 0 bis 3 im Timer-Counter-Controll-Register1B wählen von 5 möglichen Werten einen aus.
TCCR1B.2 |
TCCR1B.1 |
TCCR1B.0 |
Teiler |
0 |
0 |
0 |
Takt aus |
0 |
0 |
1 |
1 |
0 |
1 |
0 |
8 |
0 |
1 |
1 |
64 |
1 |
0 |
0 |
256 |
1 |
0 |
1 |
1024 |
Der Teiler wird eingestellt, indem man im TCCR1B die Bit-Werte entsprechend setzt. val bringt die vom ESP8266-01 gesendete Konfiguration mit. TCCR1B wird ausgelesen, die drei untersten Bits durch Undieren gelöscht, mit val oderiert und das Byte nach TTCR1B zurückgeschrieben.
Warnung: Die Veränderung von Prescaler-Wert und Auflösung kann andere Prozesse, die ebenfalls den Timer TCNT1 nutzen beeinflussen!
void setPrescaler(uint8_t val){
TCCR1B = (TCCR1B & 0xF8) | val;
}
Je größer die Auflösung, also die Bitbreite eines PWM-Werts ist, desto feinere Abstufungen sind möglich. Der TCNT1 bietet mit seinem 16-Bit breiten Zählerregister die Möglichkeit, zwischen einer 8, 9 und 10 Bit breiten Auflösung zu wählen. Eingestellt wird das mit den Bits 1 und 0 im TCCR1A-Register. Der ESP8266-01 übermittelt die gewünschte Bitbreite. Wenn ich von diesem Wert in val 7 subtrahiere, erhalte ich die drei verschiedenen Bitwerte, die ich in das Register TCCR1A oderieren muss.
TCCR1A.1 |
TCCR1A.0 |
Auflösung |
Anzahl der Stufen |
0 |
1 |
8 |
255 |
1 |
0 |
9 |
511 |
1 |
1 |
10 |
1023 |
void aufloesung(uint8_t val){
TCCR1A = (TCCR1A & 0xFC) | (val-7);
}
Die Formel, nach der die PWM-Frequenz zu berechnen ist, habe ich in Abbildung 7 schon einmal angegeben.
PWM-Frequenz = IO-System-Takt / (Vorteiler * Anzahl der Stufen)
Bleibt noch ein Blick auf die erweiterte Main-Loop. Hinzugekommen sind die fett dargestellten Zeilen. Natürlich könnte man das Aufdröseln der Kommandos eleganter mit einer switch – case – Struktur lösen.
void loop() {
if (nachricht) {
command=received[0];
if (command==0){
writePrt(received[1],received[2]);
}
if (command==1){
writeDDR(received[1],received[2]);
}
if (command==2){
readPrt(received[1],received[2]);
}
if (command==3){
readDDR(received[1]);
}
if (command==4){
wait(received[1]);
}
if (command==5){
analogOutput(received[1],received[2],received[3]);
}
if (command==6){
analogInput(received[1]);
}
if (command==7){
aufloesung(received[1]);
}
if (command==8){
setPrescaler(received[1]);
}
nachricht=false;
}
}
Testphase
Wie in der letzten Folge flashen wir zunächst den Nano V3. Sie können den Sketch tastatur.ino hier komplett herunterladen.
Für den ESP8266-01 brauchen wir zwei Dateien. Das Modul arduino_i2c.py können Sie ebenfalls herunterladen. Kopieren Sie die Datei in ihr Arbeitsverzeichnis, um es im Workspace von Thonny anzuzeigen. Mit einem Rechtsklick auf die Datei öffnen Sie das Kontextmenü. Laden Sie nun die Datei auf den ESP8266-01 hoch. Als nächstes brauchen Sie die Datei ardutest.py, die Sie auch herunterladen oder den Text selbst in einem neuen Editorfenster eingeben können.
from machine import SoftI2C, Pin
from arduino_i2c import *
# from time import ticks_ms, sleep
SCL=Pin(2)
SDA=Pin(0)
i2c=SoftI2C(scl=SCL, sda=SDA, freq=100000)
a=ARDUINO(i2c,u0=4.73)
Messen Sie jetzt die Betriebsspannung Ihres Nano V3 am 5V-Pin mit einem DVM und tragen Sie den Messwert statt 4.73 in der letzten Zeile ein.
Danach starten Sie das Programm mit F5.
>>> %Run -c $EDITOR_CONTENT
Constructor of Arduino-Interface
Arduino @ 0x24
Jetzt können alle Methoden der Klasse ARDUINO im Terminalbereich von Thonny von Hand aufgerufen werden. Dazu benutzen wir die erzeugte Instanz a. Beachten Sie bitte die Groß-Klein-Schreibung!
Beginnen wir mit der RGB-LED.
a.writeIO(DDR,C,7) # Bit 0 bis 2 von PORTC auf Ausgang
3
a.writeIO(PORT,C,1) # Bit 0 auf 1
3
Und die blaue LED leuchtet.
a.writeIO(PORT,C,2) # Bit 1 auf 1, Bit 0 auf 0
3
Aus blau wird grün.
a.writeIO(PORT,C,4) # Bit 2 auf 1, Bit 1 auf 0
3
Wechselt grün zu rot.
oder
a.writeIO(PORT,C,0b110) # Bits 2 und 1 auf 1, Bit 0 auf 0
3
Das ergibt gelb, wenn die Widerstände auf die LEDs abgestimmt sind.
a.writeIO(PORT,C,0b101) # Bits 2 und 0 auf 1, Bit 1 auf 0
3
Rot und blau ergibt magenta.
a.setBit(DDR,D,3,1) # Bit 3 von PORTD auf Ausgang
a.setBit(PORT,D,3,1) # LED leuchtet
a.setBit(PORT,D,3,0) # LED erlischt
Verbinden Sie jetzt Eingang A3 mit der 3,3V-Versorgung.
a.readAnalog(3)
3.13
Die Spannung auf der 3,3V-Leitung ist 3,13V. Wiederholte Aufrufe bringen bei 2 Nachkommastellen meist (nahezu) identische Werte.
>>> a.readAnalog(3,False)
679
Das ist der Rohwert vom ADC. Raten Sie mal, was bei dieser Rechnung herauskommt?
Ganzzahl(679 *4,73 /1023 * 102) / 102 = ?
>>> a.readAnalog(3,digits=4)
3.1348
Der optionale Parameter voltage wird per default mit True belegt, aber der Parameter digits muss explizit genannt werden.
>>> a.writeAnalog(3,127)
4
>>> a.writeAnalog(3,64)
4
>>> a.writeAnalog(3,32)
4
>>> a.writeAnalog(3,16)
4
Die LED an D3 wird zunehmend dunkler.
>>> a.writeAnalog(3,127)
4
>>> a.writeAnalog(3,255)
4
>>> a.writeAnalog(3,192)
4
Die Helligkeitsunterschiede in der oberen Hälfte sind weit weniger markant.
Am DSO (aka Digitales Speicher-Oszilloskop) sieht das so aus:
Abbildung 9: PWM mit D=25% (63 von 255)
Abbildung 10: PWM mit D=75% (191 von 255)
Mit dem Nano V3 mit ATmega328 verfügen Sie jetzt, neben den anderen Features, über eine "intelligente" 16er-Tastatur, die sie über den I2C-Bus ansteuern können. Damit lässt sich auch ganz gut in MicroPython eine Input-Funktion zum Eingeben von Ziffern realisieren. Acht GPIO-Pins am ESP8266/ESP32 werden dadurch frei und dazu einiges an Speicherplatz. Ein Display wird dafür aber dringendst empfohlen. Wie so etwas zu realisieren ist, das finden Sie hier. Links zu weiteren meiner Projekte gibt es hier.
Ich wünsche viel Vergnügen bei Ihren Experimenten mit Ihrer AVR-eierlegenden-Wollmilchsau am I2C-Bus! Übrigens, in meinem nächsten Beitrag wird es um ein Geschicklichkeitsspiel gehen. Bis dann!