. | Es ist schon mühselig, auf seinen Teebeutel zu achten. Fast immer, wenn ich einen Tee koche, kommt etwas dazwischen und der Tee gerät in Vergessenheit. Glücklicherweise besitze ich eine Tasse mit meinem Namen darauf. Irgendwann kommt dann ein Kollege und erklärt stolz, meine Tasse gefunden zu haben. Der Tee ist hat mittlerweile sehr lange gezogen, ist kalt geworden und man kann ihn nicht mehr trinken. Da muss eine Automatik her! |
Im Blog von Brian
McEvoy habe ich dann dieses
Gerät gefunden:
Hier mein Nachbau:
Inhaltsverzeichnis
1. Der PlanEs muss ein automatisierter Tee-Dipper her! Ein Gestell aus dem 3D-Drucker, ein Servo zum Bewegen eines Hebels, an dessen Ende eine Klammer zum Anheften des Teebeutels. Dazu kommen ein paar Tasten zum Einschalten und Festlegen der Zieh-Dauer, eine Anzeige der Restzeit und ein Tongeber, der nach Ablauf der vorgegebenen Zeit Alarm schlägt. Das Ganze im Batterie-Betrieb. Und ein Mikrocontroller, der das alles steuert.
Batterie-Spannung durch Restlaufzeit oder Prozent-Angabe ersetzen.
Die 3D-Druck-Vorlagen für die Dipper-Mechanik habe ich der Vorbild ohne Modifikationen entnommen. Hier sind die Duplikate der Dateien:
Zum Bewegen des Arms habe ich einen Servo vom Typ SG90 9 g Micro Servo benutzt. Zu den Spezifikationen gibt es bei den verschiedenen Quellen leicht unterschiedliche Angaben.
Die Verdrahtung wurde so gewählt, dass die Führung der Leiterbahnen möglichst einfach ist. Es wurde z.B. darauf verzichtet, die einzelnen Segmente des Sieben-Segment-Displays an einen gemeinsamen Port zu legen, damit z.B. alle Segmente gleichzeitig mit einer Anweisung angesprochen werden können. Die Konsequenzen müssen durch die zugehörige Software ausglichen werden. Beim angeführten Beispiel heißt dies, dass jedes Segment einzeln angesteuert werden muss (s.u. Abschnitt Software).
Für die Eingabe-Tasten habe ich farbige quadratische Tasten von
Adafruit
(Artikel 1010) gewählt. 15 Stück kosten nur etwa 6,- € und sie
sehen gut aus. Die Tasten sind an die µC-Pins mit einem hohen Pull-Up-Widerstand
gegen VCC angeschlossen. Beim Drücken ziehen sie den Pin auf Masse. |
Das Ganze soll im Batterie-Betrieb laufen. Vier AA-Zellen liefern nominal 6 Volt. Das ist für einen ATmega zu viel! Ein ATmega verträgt bis zu 5,5 Volt, wobei anzunehmen ist, dass er bei einer kleinen Überspannung auch nicht sofort den Geist aufgibt. Eine in Durchlassrichtung geschaltete Diode reduziert die Versorgungsspannung um ca. 0,7 Volt auf etwa 5,3 Volt.
Der Chip wird mit 8 MHz betrieben und funktioniert bei dieser Frequenz bis herunter zu etwa 3 Volt, je nach Typ auch noch weniger. Zieht man die 0,7 Volt Diodenspannung ab, kann die Batterie-Spannung bis auf etwa 3,7 Volt sinken. Bei dieser Spannung sind die Batterien im auch Wesentlichen leer und müssen getauscht werden.
Von Seiten der Stromversorgung für den µC scheint also alles in Ordnung zu sein. Mal schauen, wie weit das Sieben-Segment-Display und der Signalgeber mitmachen. Z.Zt. beträgt die Batteriespannung etwa 5,6 Volt und es klappt noch alles einwandfrei.
Um den Servo, insbesondere bei leeren Batterien, mit möglichst hoher Spannung zu versorgen, ist er direkt an die Batterien angeschlossen (nur. über einen FET mit geringem, Durchlasswiderstand). Wenn die Spannung soweit wie oben beschrieben herunter gefallen ist, wird der Servo-Motor wahrscheinlich nicht mehr ausreichend Kraft haben, um den Teebeutel zu heben. Ich denke, dass der Servo-Motor der begrenzende Faktor ist.
Damit die Batterien möglichst lange halten, ist es wichtig, auf den Stromverbrauch zu achten. Während des Betriebs, wird das Sieben-Segment-Display im Pulsmodus betrieben. Hier ist es ggf. möglich die Tastgrad zu reduzieren. Damit verringert sich allerdings die Leuchtstärke.
Noch wichtiger ist es, den Stromverbrauch im Ruhezustand zu reduzieren. Der Teedipper besitzt keinen Ein/Aus-Schalter, ist also immer an der Batterie angeschlossen. Den ATmega kann man in den Sleep-Zustand versetzen, dann verbraucht er nur noch etwa 0,6 µA. Das ist ausreichend wenig. Der Servo verbraucht jedoch knapp 4 mA, wenn er nicht angesteuert wird. Geht man von einer mittleren Batterie-Kapazität bei AA-Zellen von etwa 1.600 mAh aus (im Netz schwirren Werte zwischen 800 und 2300 mAh herum), sind die Batterien nach 400 Stunden nur(!) Leerlauf, d.h. rd. 2 Wochen, leer.
Ein FET in der Versorgungsleitung des Servo ermöglicht es, den Servo bei Nichtbenutzung von der Stromversorgung zu trennen. Mein Dipper liegt jetzt etwas mehr als eine Wochen unbenutzt herum. Lediglich die Spannung habe ich hin und wieder ausgelesen. Sie beträgt die ganze Zeit konstant 5,6 Volt.
Die Eagle-Dateien zum Download: |
Um die Frontplatte zu drucken waren eine Reihe von Versuchen notwendig. Bei der Positionierung der Bauteile, die in Verbindung zu Gehäuse-Elementen stehen, z.B. Frontplatten-Ausschnitte benötigen oder Bohrlöcher, sollte man die Positionierung sehr auf der Platine sorgfältig vornehmen, z.B. durch explizite Angabe der Positionen. Dann könnte man bei bekannten Bauteil-Abmessungen die Frontplatten-Ausschnitte berechnen und müsste sie nicht ausmessen.
Das Gehäuse ist auf einem 3D-Drucker entstanden. Es besteht aus vier Komponenten. Die Schnitte wurden so gewählt, dass keine Überhänge entstanden:
Die Frontplatte ist im 3D-Drucker entstanden. Die Beschriftung wurde mit PowerPoint entworfen, gedruckt und aufgeklebt.
Frontplatte OpenScad-Vorlage | Frontplatte OpenScad-Vorlage Rückseite | Frontplatte Rückseite | Frontplatte beschriftet | Beschriftungsvorlage |
Leider hatte ich bei Herstellung der Platine nicht an die Herstellung der Frontplatte gedacht. Die Position und Abmessung der einzelnen Elemente habe ich ausmessen müssen. Dazu habe ich eine dünne Scheibe (0,8 mm) gedruckt, geschaut wo es klemmt und die Daten angepasst, bis alles am richtigen Platz war.
Die dünnen Fahnen am Rand der Platte verhindern das Verziehen des Objekts beim Druck auf Grund von unterschiedlichen Schrumpfungsraten des Objekts (Warping). Die Fahnen besitzen eine Sollbruchstelle und können einfach abgeschnitten werden.
Zunächst habe ich versucht, die Beschriftung in die Oberfläche einzulassen. Das sah aber ziemlich unordentlich aus. Die notwendige Auflösung war für meinen 3D-Drucker einfach zu hoch. Also habe ich ein anderes Verfahren gewählt.
Die Beschriftung wurde mit PowerPoint gezeichnet. Dazu habe ich vier farblich unterschiedliche Varianten entworfen (s. Abb. ganz rechts). Diese habe ich mit einem Farb-Laser-Drucker im Copy-Shop drucken lassen. Der Druck mit einem Laser-Drucker hat den Vorteil, dass die Farbe (der Toner) nicht feuchtigkeitsempfindlich ist. Außerdem sind die Druckergebnisse besser als bei einem Tintenstrahldrucker. Das Papier war Standardpapier (80 g/m²). Allerdings war die Oberfläche sehr glatt. Letztendlich habe ich mich für die hellblaue variante entschieden.
Die Vorlage habe ich grob ausgeschnitten und mit einem Laminier-Gerät versiegelt. Die Folie habe ich dann auf die Frontplatte gelegt und die Ränder markiert. Diese Markierungen sind notwendig, weil man beim späteren Aufkleben nur einen Versuch hat. Ein nachträgliches Verschieben ist nicht möglich.
Die Frontplatte habe ich mit Sprühkleber eingesprüht. Leider hatte ich nur Sprühkleber vom Type "non-permanent" zur Verfügung. Hier kommt man weiter, wenn man beide Teile einsprüht und den Kleber vor dem Zusammenfügen der Teile ablüften lässt. Dann muss die Frontplatte passgerecht auf die Folie aufgesetzt (vorher Markierungen abringen, s.o.) und mit einem weichen Tuch kräftig angedrückt werden.
Im nächsten Schritt wurden die Ränder der Folie mit einem Skalpell passend ab- und die Löcher für die Bedienelemente ausgeschnitten. Durch das Beschneiden liegen die Papierkanten offen. Diese Kanten sind zwar sehr schmal, aber Flüssigkeiten treten auf Grund des Kapillar-Effekts dennoch ein. Das gibt hässliche Flecken! Um die Kanten zu versiegeln habe ich die Oberfläche an den Rändern (Außenränder und Ausschnitte) mit Tesafilm so abgeklebt, dass ein Teil des Streifens auf der Folie klebt und der andere in der Luft hängt. Die Ecken müssen fest angedrückt werden, damit der Tesafilm dicht auf der Oberfläche der Folie liegt. In die so entstandene Kehle wird Sekundenkleber gegeben (s. folgende Grafik). Das Tesafilm schützt die Folienoberfläche, der Sekundenkleber kann nicht auf die Oberseite gelangen. Der Sekundenkleber versiegelt die offen liegende Papierkante uns verbessert die Verklebung der Folie mit der Frontplatte. Insbesondere an den Rändern ist eine gute Verklebung wichtig. Der Klebefilmkann wegen der geringen Viskosität dünn aufgetragen werden.
Nach dem Abtrocken des Klebers wird der Tesafilm wieder abgezogen. Die entstanden Grate des Sekundenklebers lassen sich mit einer harten Kante (Rückseite eines Messers) abkratzen. Schneiden verletzt die Kleberschicht evtl. so stark, dass die Versiegelung unterbrochen wird.
Ich habe versehentlich beide Seiten des Blattes laminiert. Eigentlich wollte ich dies nur bei der Vorderseite machen. Dann wäre auf der Rückseite das nackte Papier geblieben. Zum späteren Aufkleben wäre dies wahrscheinlich besser geworden.
Hinweis: Die Frontplatte erst dann mit dem Rahmen verkleben, wenn die Bodenplatte montiert ist (s.u.)!
Das Gehäuse besteht aus zwei Teilen, die aufeinander geklebt wurden. Die Klebeflächen sind auf der jeweiligen Unterseite der Grafiken.
Rahmen Oberteil Platinen |
Rahmen Unterteil Batterie |
Gedruckter und verklebter Rahmen |
Das Oberteil besitzt einen Einlassrand, in den den die Frontplatte eingesetzt und festgeklebt wird. Der Rahmenhöhe ist durch die Höhe der Platinen bestimmt.
Das Unterteil besitzt ebenfalls einen Einlassrand für die Bodenplatte. Es bietet Raum für den Batterie-Halter. An den vier Ecken sind viereckige Bolzen mit Löchern zur Aufnahme von Schrauben mit selbstschneidendem oder selbstprägendem Gewinde (Blechschrauben, o.ä.) ausgebildet (Nachtrag: hat nicht gut funktioniert, s. Abschnitt Optimierung). Ein Schlitz an der Seite dient als Durchlass für das Servo-Kabel.
Hinweis: Die Frontplatte erst dann mit dem Rahmen verkleben, wenn die Bodenplatte montiert ist (s.u.)!
Bodenplatte montiert |
Batterie-Halter | Bodenplatte 3D-Teile Sicht von unten |
Die Bodenplatte besteht aus einer Platte mit versenkten Schraublöchern. Diese Plate ist zu dünn um Schrauben aufnehmen zu können. Deshalb gibt es zwei Distanzstücke für den Batterie-Halter. Am besten schraubt zunächst man die Distanzstücke soweit an den Batterie-Halter an, dass die Schrauben eben noch nicht hindurch treten. Diese Konstruktion klebt man auf die Bodenplatte. Das Kleben sollte mit aufgestecktem Batterie-Clip erfolgen. Ebenfalls sollte die Bodenplatte im Rahmen eingelassen sein. So kann man den Batterie-Halter passend positionieren.
Frontpanel montiert |
Komplett montiert |
Die 3D-Druck-Dateien zum Download: |
Ich habe beide Seiten der Papierauflage für die Frontplatte laminiert. Eigentlich wollte ich dies nur bei der Vorderseite machen. Dann wäre auf der Rückseite das nackte Papier geblieben. Zur Haftung nach dem Aufkleben wäre dies wahrscheinlich besser gewesen. Folien lassen sich je nach Material ggf. schlecht kleben.
Die Frontplatte schließt bündig mit der Oberseite des Gehäuses ab. Sie steht sogar auf Grund der Ungenauigkeiten beim Druck ein wenig hervor und das aufgelegte laminierte Papier verstärkt dies. Es sähe wahrscheinlich besser aus, wenn die Platte ein wenig eingelassen wäre. Außerdem wäre die Haltbarkeit besser, weil die Außenkante des Papiers und durch das hervorstehende Gehäuse geschützt wäre.
Die Bodenplatte musste auf Grund der Ungenauigkeiten beim Druck an den Rändern stark abgeschliffen werden, damit es in die Einlassung des Rahmen-Unterteils passte. Es sollte etwas kleiner gemacht werden.
Probleme gab es mit den Befestigungsschrauben für die Bodenplatte. Der Rahmen wurde mit 2 Shells und geringem Infill-Grad gedruckt. Beim Eindrehen der Schraube hat sich der Zylinder zur Aufnahme der Schraube aus dem Infill heraus gedreht. Entweder man benutzt reguläre Gewindeschrauben (z.B. M3) und sieht Aufnahmen für die Muttern vor oder man druckt die Aufnahme-Blöcke separat und wesentlich stabiler und klebt sie anschließend in den Rahmen ein. Den gesamten Rahmen verstärkt zu drucken ist nicht sinnvoll.
Bei der Erstellung der Software habe ich mir Mühe gegeben, möglichst strukturiert vorzugehen. Für jede Funktionseinheit gibt es ein eigenes Modul:
Modul | Funktion | Anmerkung |
Main.c | Hauptprogramm, Initialisierung und Gesamtsteuerung | |
Key (.c, .h) | Interruptgetriebene Kontrolle der Tasten | |
Melody (.c, .h) | Interruptgetriebene Erzeugung der Ende-Fanfare | siehe RTTTL: Melodien mit einem AVR ♫♬♯♪ |
Servo (.c, .h) | Interruptgetriebene Servo-Steuerung | |
SevenSeg (.c, .h) | Interruptgetriebene Steuerung des Sieben-Segment-Displays | |
Time (.c, .h) | Interruptgetriebener Zeitgeber für die Dipp-Zeit | |
ADC (.c, .h, .cfg) | Ansteuerung des ADC | siehe ADC: Mehr als 0 und 1 |
AppVersion.h | Verwaltung der Versionsnummer | siehe Build-Nummer: Immer Eins drauf! |
GeneralDefinitions.h | Allgemeine Hilfs-Makros | siehe Bibliothek: Kein zweites Mal! |
Ressources.h | Festlegung der benutzten Prozessor-Ressourcen an zentraler Stelle |
Timer0: LED-Steuerung
Timer1: Servo-Steuerung
Timer2: Zeitmessung
für die Dipp-Zeit &
Ton-Erzeugung
Zeitmessung und Ton-Erzeugung können mit dem gleichen Timer erfolgen, da die Ende-Fanfare erst nach Abschluss des Dippens ausgegeben wird.
Die Verbindung der Peripherie-Bauteil mit dem µC erfolgte so, dass ein möglichst unkomplizierter Leiterbahnverlauf erreicht wurde. Demzufolge sind verwandte Funktionen (wie z.B. die Segment-Ansteuerung des Displays) nicht auf einem Port angebracht. Das hat zur Folge, dass jeder Pin einzeln angesteuert werden muss. Dies führt zu etwas größeren Programmen und zu längeren Laufzeiten. Beides ist kein Problem und z.B. beim Arduino Standard.
Ansonsten muss bedacht werden, dass
Exemplarisch seien hier die Definitionen für die Taster angeführt. Die weiteren Definitionen befinden sich in der Datei Ressources.h.
// Taster
#define Button1Port PORTB
#define Button2Port PORTB
#define Button3Port PORTD
#define Button1Pin PB4 // PCINT4
#define Button2Pin PB5 // PCINT5
#define Button3Pin PD5 // PCINT21
/*
* Key.h
*
* Projekt: Teedipper
* Created: 15.08.2015
* Author: Ulli
*/
#ifndef KEY_H_
#define KEY_H_
void KeyInit(void);
extern volatile uint8_t ButtonState;
// Masken für die einzelnen Taster
#define Button1 1 // 3 Minuten
#define Button2 2 // 5 Minuten
#define Button3 4 // 7 Minuten
#define ButtonStp (1+2) // Stopp
#define ButtonTst (2+4) // Batterie-Test
#endif /* KEY_H_ */
Das Taster-API stellt die Funktion KeyInit() zur Verfügung, mit der die Taster-Steuerung initialisiert wird, und die globale Variable ButtonState, über die der aktuelle Zustand der Taster abgefragt werden kann. Für die einzelnen Zustände sind die Konstanten Button1 ... ButtonTst hinterlegt.
Die Funktion KeyInit() gibt die Pin-Change-Interrupts (PCINT) frei.
void KeyInit(void)
{ // Pin Change Interrupts freigeben
PCMSK0 = _BV(PCINT5)|_BV(PCINT4); // Pin change mask register
PCMSK2 = _BV(PCINT21);
PCICR = _BV(PCIE2)|_BV(PCIE0); //Pin change interrupt control register
}
Die zugehörigen Interrupt-Service-Routinen rufen lediglich die Funktion ChkButtons() auf.
ISR(PCINT0_vect)
{ ChkButtons();
}
ISR(PCINT2_vect)
{ ChkButtons();
}
Diese fragt die betroffenen PIN-Register ab, ermittelt den Tasten-Zustand und legt ihn in der globalen Variablen ButtonState ab.
inline void ChkButtons(void) // Gedrückte Taste zieht auf Masse
{ if (PIN(Button1Port) & _BV(Button1Pin))
ButtonState |= Button1;
else
ButtonState &= ~Button1;
if (PIN(Button2Port) & _BV(Button2Pin))
ButtonState |= Button2;
else
ButtonState &= ~Button2;
if (PIN(Button3Port) & _BV(Button3Pin))
ButtonState |= Button3;
else
ButtonState &= ~Button3;
}
/*
* Time.h
*
* Projekt: Teedipper
* Created: 15.08.2015
* Author: Ulli
*/
#ifndef TIME_H_
#define TIME_H_
void StartDippingClock(uint16_t Seconds); // Zählt RemainingSeconds im Sekundentakt bis 0 herunter
extern volatile uint16_t RemainingSeconds;
void delay100();
void delay50();
void delay25();
#endif /* TIME_H_ */
Das Zeitgeber-API stellt die Funktion StartDippingClock(uint16_t Seconds) bereit, mit der ein Sekundenzähler gestartet wird. Als Parameter wird übergeben, wie lange der Zähler laufen soll. Über die globale Variable RemainingSeconds kann die Restzeit abgefragt werden.
Hinzu kommen die Funktionen delay25() ... delay100(), die eine Warteschleife der entsprechenden Länge in Millisekunden implementieren. Das direkte Einbindungen des Makros _delay_ms() würde wegen der Mehrfachnutzung dazu führen, dass der Programmspeicher überläuft.
StartDippingClock() initialisiert TIMER2 mit einem Prescaler von 256 und gibt den Overflow-Interrupt frei.
void StartDippingClock(uint16_t Seconds)
{ RemainingSeconds = Seconds;
LoopCount = 0;
// Timer starten
TCCR2A = 0;
TCCR2B = _BV(CS22)|_BV(CS21); // Normal Mode, Prescaler: 256
TIMSK2 |= _BV(TOIE2); // Overflow Interrupt freigeben
}
Die Timer-OVF-ISR ist recht einfach aufgebaut. Bei 8 MHz Taktfrequenz, einem Prescaler von 256 und einem 8-Bit-Zähler ergibt sich eine Frequenz von 8.000.000 / 256 / 256 ≈ 122 Hz mit der der Overflow eintritt. Die lokale Variable LoopCount zählt mit, wie häufig der Interrupt ausgelöst wurde und erniedrigt zu gegebener Zeit die globale Variable RemainingSeconds. Ist diese bei 0 angekommen, wird der Timer ausgeschaltet (von der Taktquelle getrennt).
// 8.000.000 / 256 (8-Bit-Zähler) / 256 (Prescaler) = 122,0703125 Interrupt-Auslösungen machen eine Sekunde aus
#define OvfLoops 124 // 122 war etwas zu schnell
ISR(TIMER2_OVF_vect)
{ if(++LoopCount > OvfLoops)
{ if (--RemainingSeconds == 0)
TCCR2B = 0; // Timer wieder aus
else
LoopCount = 0;
}
}
Ich habe später noch einmal das laufende Gerät mit einer Uhr überprüft und daraufhin die Anzahl der ISR-Auslösungen auf eine Sekunde nachgebessert.
Bleiben noch die Zeitschleifen. Die Konstruktion soll, wie bereits oben beschrieben, dafür sorgen, dass der Kompiler nicht mehrfach das Makro _delay_ms in den Code einbindet. Dies würde zum Überlauf des Programmspeichers führen.
void __attribute__ ((noinline)) delay25(void)
{ _delay_ms(25);
}
void __attribute__ ((noinline)) delay50(void)
{ delay25();
delay25();
}
void delay100()
{ delay50();
delay50();
}
/*
* SevenSeg.h
*
* Projekt: Teedipper
* Created: 15.08.2015
* Author: Ulli
*/
#ifndef SEVENSEG_H_
#define SEVENSEG_H_
#include "GeneralDefinitions.h"
void SevenSegInit(void); // Ports und Timer zur Ansteuerung der 7-Segment-Anzeige vorbereiten
void SevenSegStop(void); // Ports und Timer zurück in den Originalzustand
void SetSevenSeg(uint8_t Digit, uint8_t Value); // 0..9: Ziffern, 10: -, 11..17: A..G, 21: aus
void SetSevenSegOff(uint8_t Digit); // 0..9: Ziffern, 10: -, 11..17: A..G, 21: aus
void SetSevenSegAllOff(void);
void SetSevenSegDP(uint8_t Digit); // 0..2 = Digit, sonst aus
void SetSevenSegBlink(bool Blink);
#endif /* SEVENSEG_H_ */
Das API stellt eine Reihe von Funktionen zur Verfügung.
Funktion | Wirkung |
SevenSegInit() | Initialisierung des Timers und der Ports |
SevenSegStop() | Versetzt die Ressourcen zurück in den Originalzustand |
SetSevenSeg() | Ausgabe des Symbols mit der übergebenen Symbolnummer im angegebenen Digit |
SetSevenSegOff() | Das angegebene Digit ausschalten |
SetSevenSegAllOff() | Alle Digits ausschalten |
SetSevenSegDP() | Den Dezimalpunkt am angegeben Dezimalpunkt einschalten |
SetSevenSegBlink() | Blinkende Anzeige |
Um die Ansteuerung des Display einigermaßen übersichtlich gestalten zu können, habe ich zunächst einmal eine Möglichkeit geschaffen, auf einzelne Elemente (Segmente(Kathoden), Digits(gemeinsame Anode)) des Displays einfach zugreifen zu können.
Die Struktur PinAccess_t dient dazu, sowohl den Port als auch den Pin des Elements abzulegen. Wichtig ist, den korrekten Typ für die Port-Angabe zu wählen. Dieser muss volatile uint8_t * sein (s. mikrocontroller.net: AVR-GCC-Tutorial).
// Structure, um ein einzelnen Pin zu adressieren
typedef struct
{ volatile uint8_t * Port; // Adresse des Ports, zu dem der Pin gehört
uint8_t PinMask; // Maske des Pins im Port
} PinAccess_t;
Die Elemente des Displays werden dann einfach als Arrays dieses Typs angelegt.
// Pin-Adressen der gemeinsamen Anoden
static PinAccess_t Digits[] = { {&PortDigit1, PinDigit1},
{&PortDigit2, PinDigit2},
{&PortDigit3, PinDigit3} };
// Pin-Adressen der Segmente
static PinAccess_t Segments[] = {{&PortSegA, PinSegA},
{&PortSegB, PinSegB},
{&PortSegC, PinSegC},
{&PortSegD, PinSegD},
{&PortSegE, PinSegE},
{&PortSegF, PinSegF},
{&PortSegG, PinSegG},
{&PortSegDP, PinSegDP} };
Der Einfachheit halber habe ich noch Funktionen zum Ein- und Ausschalten der einzelnen Elemente definiert.
// Die folgenden vier Methoden dienen zum Ein- und Ausschalten der Ziffern und der Segmente
// Sie sind ausgelegt für eine gemeinsame Anode
// Bei gemeinsamer Anode müssen die Polaritäten getauscht werden (d.h. Off <-> On)
inline static void DigitOn(uint8_t DigitNo) // Digit einschalten
{ *Digits[DigitNo].Port |= Digits[DigitNo].PinMask;
}
inline static void DigitOff(uint8_t DigitNo) // Digit einschalten
{ *Digits[DigitNo].Port &= ~Digits[DigitNo].PinMask;
}
inline static void SegmentOn(uint8_t SegmentNo) // Segment einschalten
{ *Segments[SegmentNo].Port &= ~Segments[SegmentNo].PinMask;
}
inline static void SegmentOff(uint8_t SegmentNo) // Segment ausschalten
{ *Segments[SegmentNo].Port |= Segments[SegmentNo].PinMask;
}
Im nächsten Schritt geht es darum, die Symbole zu definieren, die angezeigt werden sollen. Dies sind zunächst die Ziffern 0 .. 9, Symbole für ein Laufsymbol (#10..20), das am Ende der Dipp-Zeit, während des Abspielens der Fanfare animiert werden soll, und ein Symbol (#21), bei dem kein Segment leuchtet. Das Makro Symbol hilft beim Anlegen der Daten.
#define Symbol(A, B, C, D, E, F, G) ((A<<7) + (B<<6) + (C<<5) + (D<<4) + (E<<3) + (F<<2) + (G<<1))
// A B C D E F G
uint8_t Symbols[] = { Symbol(1, 1, 1, 1, 1, 1, 0), // 0
Symbol(0, 1, 1, 0, 0, 0, 0), // 1
Symbol(1, 1, 0, 1, 1, 0, 1), // 2
Symbol(1, 1, 1, 1, 0, 0, 1), // 3
Symbol(0, 1, 1, 0, 0, 1, 1), // 4
Symbol(1, 0, 1, 1, 0, 1, 1), // 5
Symbol(1, 0, 1, 1, 1, 1, 1), // 6
Symbol(1, 1, 1, 0, 0, 0, 0), // 7
Symbol(1, 1, 1, 1, 1, 1, 1), // 8
Symbol(1, 1, 1, 1, 0, 1, 1), // 9
Symbol(0, 0, 0, 0, 0, 0, 1), // 10= -
// Lauf-Symbol
// A B C D E F G
Symbol(1, 0, 0, 0, 0, 0, 0), // 11
Symbol(1, 0, 0, 0, 0, 1, 0), // 12
Symbol(0, 0, 0, 0, 0, 1, 1), // 13
Symbol(0, 0, 1, 0, 0, 0, 1), // 14
Symbol(0, 0, 1, 1, 0, 0, 0), // 15
Symbol(0, 0, 0, 1, 0, 0, 0), // 16
Symbol(0, 0, 0, 1, 1, 0, 0), // 17
Symbol(0, 0, 0, 0, 1, 0, 1), // 18
Symbol(0, 1, 0, 0, 0, 0, 1), // 19
Symbol(1, 1, 0, 0, 0, 0, 0), // 20
Symbol(0, 0, 0, 0, 0, 0, 0)};// 21 = Aus
#0 | #1 | #2 | #3 | #4 | #5 | #6 | #7 | #8 | #9 | #10 | #11 | #12 | #13 | #14 | #15 | #16 | #17 | #18 | #19 | #20 | #21 |
Drei lokale Variablen dienen zur Speicherung des aktuellen Anzeige-Zustands:
// Aktuelle Belegung des Displays
static uint8_t Display[3]; // 0 Links, 1 Mitte, 2 Rechts
static uint8_t DpPos = 3; // Position DezimalPunkt. >2: Aus
static bool DoBlink = false; // Gibt an, ob die Anzeige blinken soll
Das Array Display nimmt das aktuelle Segment-Muster für die drei Ziffern auf. DpPos gibt an, bei welchem Digit der Dezimalpunkt angezeigt werden soll. Ein Wert > 2 schaltet den Punkt aus. DoBlink gibt an, ob die Anzeige blinken soll. Dazu kommen die öffentlichen Funktion zur Befüllung dieser Variablen:
// Belegt die Display mit dem anzuzeigenden Wert
void SetSevenSeg(uint8_t Digit, uint8_t Value)
{ if (Digit < 3)
Display[Digit] = Symbols[Value];
if (DpPos < 3)
Display[DpPos] |= 1; // Dezimalpunkt auf dem letztem Bit
}
void SetSevenSegOff(uint8_t Digit)
{ if (Digit < 3)
Display[Digit] = 0;
}
// Legt fest, wo der Dezimalpunkt angezeigt wird ( >2: keine Anzeige
void SetSevenSegDP(uint8_t Digit)
{ Display[DpPos] &= ~1; // Alten Punkt ausschalten
DpPos = Digit;
if (DpPos < 3)
Display[DpPos] |= 1;
}
// legt fest, ob die Anzeige blinken soll.
void SetSevenSegBlink(bool Blink)
{ DoBlink = Blink;
}
void SetSevenSegAllOff()
{ SetSevenSegBlink(false);
SetSevenSegOff(0);
SetSevenSegOff(1);
SetSevenSegOff(2);
SetSevenSegDP(3);
}
Die Funktionen sind nicht schwierig zu verstehen.
Zwei Funktionen schalten die Display-Steuerung ein und aus:
void SevenSegInit(void)
{ // Pins für die Segmente auf Output
DDR(PortSegA) |= PinSegA; // Segment auf Output
DDR(PortSegB) |= PinSegB; // Segment auf Output
DDR(PortSegC) |= PinSegC; // Segment auf Output
DDR(PortSegD) |= PinSegD; // Segment auf Output
DDR(PortSegE) |= PinSegE; // Segment auf Output
DDR(PortSegF) |= PinSegF; // Segment auf Output
DDR(PortSegG) |= PinSegG; // Segment auf Output
DDR(PortSegDP) |= PinSegDP; // Segment auf Output
// Pins für die Anoden auf Output
DDR(PortDigit1) |= PinDigit1; // Gemeinsame Anode/Kathode Digit1: Output
DDR(PortDigit2) |= PinDigit2; // Gemeinsame Anode/Kathode Digit2: Output
DDR(PortDigit3) |= PinDigit3; // Gemeinsame Anode/Kathode Digit3: Output
// Timer0 starten
TCCR0B = _BV(CS01); // Normal Mode, Prescaler 8, f ca. 4 kHz, Taktdauer ca. 0,25 ms
TIFR0 = _BV(TOV0); // Gff. gesetztes Interrupt-Flag löschen
TIMSK0 = _BV(TOIE0); // Timer/Counter0 overflow interrupt enable
}
SevenSegInit() schaltet das Display ein. Zuerst werden alle betroffenen Pins in den Output-Modus geschaltet, danach Timer0 gestartet und der Overflow-Interrupt frei gegeben. Der Timer wird so eingestellt, das der Overflow-Interrupt etwa alle 0,25 ms ausgelöst wird. Das ist auch die Brenndauer für ein einzelnes Segment.
void SevenSegStop(void)
{ // Timer 0 ist exklusiv für die 7-Segment-Steuerung reserviert
TCCR0B = 0; // Alle Register zurück auf 0
TIMSK0 = 0;
SetSevenSegAllOff();
// Alle Ports auf Input
DDR(PortSegA) &= ~PinSegA; // Pins zurück auf Input
DDR(PortSegB) &= ~PinSegB;
DDR(PortSegC) &= ~PinSegC;
DDR(PortSegD) &= ~PinSegD;
DDR(PortSegE) &= ~PinSegE;
DDR(PortSegF) &= ~PinSegF;
DDR(PortSegG) &= ~PinSegG;
DDR(PortSegDP) &= ~PinSegDP;
DDR(PortDigit1) &= ~PinDigit1;
DDR(PortDigit2) &= ~PinDigit2;
DDR(PortDigit3) &= ~PinDigit3;
}
Die genau gegenteilige Wirkung hat SevenSegStop().
Als Letztes kommt die ISR, die das Display ansteuert. ActDigit (0..2) und ActSegment (0..7) dienen zur Speicherung des aktuell leuchtenden Segment und des Digits.
Das Blinken wird durch Abzählen des 0,25-ms-Takts gesteuert. Die Konstante BlinkPhase bestimmt, wie viele Takte eine Blink-Phase (jeweils An und Aus) dauern soll. Aktuell ist 1000 eingestellt. Eine einzelne Phase dauert somit 250 ms. Die Blinkfrequenz ist also etwa 2 Hz. ActBlinkCount zählt die in der aktuellen Phase verbrachten Takte. ActBlink hält fest, ob die aktuelle Phase AN oder AUS ist.
#define BlinkPhase 1000 // bei 0,25 ms pro Takt sind dies ca. 250ms
static uint8_t ActDigit = 0; // Aktuell geschaltete Ziffer
static uint8_t ActSegment; // Aktuell geschaltetes Segment
static uint16_t ActBlinkCount; // Anzahl verbleibender Durchläufe für diese Blink-Phase
static uint8_t ActBlink; // Aktueller Blink-Zustand, An oder Aus
Etwa alle 0,25 ms wird sie ISR aufgerufen.
// Es wird zu einer Zeit immer ein Segment angezeigt
ISR(TIMER0_OVF_vect)
{ // Alte Anzeige ausschalten
DigitOff(ActDigit);
SegmentOff(ActSegment);
// Nächstes anzuzeigende Segment ermitteln
if (++ActSegment > 7)
{ ActSegment = 0;
if (++ActDigit > 2)
ActDigit = 0;
}
if (++ActBlinkCount > BlinkPhase)
{ ActBlinkCount = 0;
ActBlink = ~ ActBlink;
}
if (!DoBlink || ActBlink)
{ // Neue Anzeige einschalten
DigitOn(ActDigit);
if(Display[ActDigit] & (1<<(7-ActSegment)))
SegmentOn(ActSegment);
}
}
Zunächst wird die alte Anzeige ausgeschaltet. Dann wird der Segment-Zähler hochgesetzt. Findet ein Überlauf statt, wird er zurück gesetzt und der Digit-Zähler erhöht.
Ist die aktuelle Blinkphase abgelaufen, wird der Taktzähler zurück gesetzt und in die andere Phase geschaltet. Dies geschieht auch dann, wenn aktuell kein Blinken eingestellt ist.
Als letztes wird das neu ermittelte Segment eingeschaltet. Falls Blinken
eingestellt ist, geschieht dies nur währen der AN-Phase. Die Anode wird auf
jeden fall eingeschaltet. Das Segment nur dann, wenn es für die Darstellung
des aktuellen Symbols notwendig ist: if(Display[ActDigit] & (1<<(7-ActSegment)))
.
Die Animation während des Abspielens der Ende-Fanfare wird im Hauptprogramm durchgeführt. Die Melodie-Ausgabe ist interruptgetrieben, so dass im Hauptprogramm die Animation mit Hilfe von einfachen Zeitschleifen realisiert werden kann. Die Symbolfolge für die Animation ist für jeweils ein Digit in den Arrays AnimationX hinterlegt. Es handelt sich um eine "8" über die gesamte Display-Breite.
#define S0 21
const uint8_t Animation0[] = {10, S0, S0, S0, S0, 16, 17, 18, 10, S0, S0, S0, S0, 11, 12, 13}; // Linkes Digit
const uint8_t Animation1[] = {10, 10, S0, S0, 16, 16, S0, S0, 10, 10, S0, S0, 11, 11, S0, S0}; // Mittleres
const uint8_t Animation2[] = {S0, 10, 14, 15, 16, S0, S0, S0, S0, 10, 19, 20, 11, S0, S0, S0}; // Rechtes Digit
Die Ausgabe erfolgt durch einspielen der Symbole in einer Schleife, die durch das Melodie-Ende oder Druck der Tastenkombination "Stopp" unterbrochen wird.
while(IsPlayingMelody())
{ for (uint8_t i=0; i < sizeof(Animation0); i++)
{ if (!IsPlayingMelody())
break;
SetSevenSeg(0,Animation0[i]);
SetSevenSeg(1,Animation1[i]);
SetSevenSeg(2,Animation2[i]);
delay50();
if (ButtonState == ButtonStp)
{ delay100(); // Entprellung
goto Exit;
}
} // for
} // while(isPlaying())
Die Ausgabe der Melodie für die Ende-Fanfare habe ich bereits an anderer Stelle beschrieben: RTTTL: Melodien mit einem AVR ♫♬♯♪.
Hinzugekommen ist die Funktion PlayRandom(), die das Abspielen einer zufällig ausgewählten Melodie erlaubt.
static const note_t * Melodies[] ={GoodBad, BurgerTime, Star, Smoke, PinkPanther, Muppets};
static uint8_t Initialized = 0;
void PlayRandom()
{ if(!Initialized)
{ srand(GetSeed());
Initialized = 1;
}
PlayMelody(Melodies[rand()%(sizeof(Melodies)/sizeof(Melodies[0]))]);
}
Das Array Melodies enthält Pointer auf die zur Verfügung stehenden Melodien. Je nach Größe des Programmspeichers können hier weitere Melodien aufgeführt werden. Hier nicht aufgeführte Melodien werden vom Kompiler nicht eingebunden und verauchen demzufolge auch keinen Speicher. Aus diesem Array wird dann eine zufällige Melodie ausgewählt.
Beim ersten Aufruf der Funktion wird der Zufallszahlengenerator auf einen zufälligen Wert eingestellt (s. Zufallswert zur Initialisierung des Zufallszahlengenerators).
Die Servo-Steuerung erfolgt vollkommen interruptgesteuert. Hierbei sind zwei Aspekte zu berücksichtigen. Das Servo benötigt alle etwa 20-50 ms einen Puls, dessen Länge (etwa 1-2 ms) die Soll-Position des Servos angibt. Dies lässt sich sehr elegant mit einen Timer im PWM-Modus realisieren. Die Gesamtdauer eines Timer-Zyklus muss dann der Periodendauer von etwa 20 ms entsprechen. Der Tastgrad (duty cycle) wird so eingestellt, dass ein Puls von 1-2 ms Dauer entsteht. Durch die Variation des Tastgrads kann die Position des Servos beeinflusst werden.
Der Timer1 (16-Bit) wird im Fast-PWM-Modus (Mode 14 im ATmega48-Datenblatt). Der Prescaler ist auf 64 eingestellt, so dass das Timer-Register jeweils in Schritten etwa 8µs erhöht wird (1s / 8.000.000 x 64).
Die Obergrenze der Zählers wird in diesem Modus durch das Register "ICR1" festgelegt. Bei einem ICR1-Wert von 2500 ergibt sich eine Pulsdauer liegt bei etwa 20 ms. Die realisierte Periodendauer liegt innerhalb des erwarteten Zeitfensters.
Das Steuersignal selbst wird über das Tastverhältnis des PWM-Signals generiert. Pin OC1A (Sonderfunktion von Pin PB1) kann so geschaltet werden, dass er beim Überlauf des Timer-Zählers auf High geht und bei Erreichen des in Register OCR1A vorgegebenen Zählerstandes wieder auf Low geht. Durch passendes Einstellen des Registers OCR1A kann die gewünschte Länge des Steuerpulses mit einer Genauigkeit von 8 µs (s.o.) erzeugt werden.
Für die Puls-Erzeugung ist keine weitere Funktionalität notwendig. Die Pulse können komplett über die Hardware des ATmega generiert werden. Für den Tee-Dipper ist es jedoch notwendig, dass der Arm langsam auf und ab bewegt wird. Das kontinuierliche Verändern der Servo-Stellung soll auch über einen Interrupt gesteuert werden, das Hauptprogramm soll sich hierum natürlich auch nicht kümmern müssen.
Praktischerweise nutzt man hierzu ebenfalls den Timer1. Es bietet sich der Overflow-Interrupt an. Die Zeitdauer für eine Periode beträgt ca. 20 ms (s.o.) . Zur Bewegung des Servo-Arms muss OCR1A zwischen den Werten 100 (Stellung oben) und 180 (Stellung unten) variieren*. Wenn man den OCR1A-Wert bei jeder 20-ms-Periode um 1 erhöht bzw. erniedrigt, würde sich der Arm in etwa 2 x (180-100) x 20 ms = 3,2 s aus und ab bewegen. Das ist eine angenehme Geschwindigkeit für einen Teebeutel . Einen 20-ms-Takt bietet bei der angegeben Konfiguration der Overflow-Interrupt.
* Die Werte wurden experimentell ermittelt und hängen davon ab, in welcher Stellung der Arm auf das Servo montiert wird. Wenn das Servo andersherum einbaut wird, wird die obere Positionen einen größeren OCR1A-Wert benötigen als die umgekehrte. Die Software berücksichtigt dies.
Die vereinfachte ISR sähe wir folgt aus (die implementierte Funktion ist etwas aufwändiger um der möglichen umgekehrten Richtung bei spiegelbildlichem Einbau des Servos Rechnung zu tragen):
#define OcrAtTop 100
#define OcrAtBottom 180
#define DirectionToBottom 1
#define DirectionToTop -1
static uint16_t Direction;
// OCR1A langsam erhöhen bzw. erniedrigen
ISR(TIMER1_OVF_vect)
{ OCR1A += Direction;
if (OCR1A > OcrAtBottom)
Direction = DirectionToTop;
if (OCR1A < OcrAtTop)
Direction = DirectionToBottom;
}
Das Modul "Servo" bietet zwei Funktionen: ServoStart() startet den o.g. Mechanismus zu beachten ist, dass der FET zur Versorgung des Servos mit Strom eingeschaltet wird. ServoStop() unterbricht den Interrupt, fährt das Servo in die Ruhestellung, schaltet die Stromversorgung des Servos aus und versetzt betroffenen Pins wieder in den Input-Modus.
#define ICR_VAL 2500
void ServoStart()
{ TCCR1A = _BV(COM1A1) | _BV(WGM11); // Fast PWM, TOP = ICR1
TCCR1B = _BV(WGM12) | _BV(WGM13) | _BV(CS11) | _BV(CS10); // Prescaler 64
ICR1 = ICR_VAL;
DDR(ServoPort) |= _BV(ServoControlPin) | _BV(ServoPowerPin); // Pins für ServoControlPin, ServoPowerPin auf Output
ServoPort |= _BV(ServoPowerPin); // Servo-Power ein
TIMSK1 = _BV(TOIE1); // Overflow Interrupt freigeben
OCR1A = OcrAtTop;
Direction = DirectionToBottom;
}
void ServoStop()
{ Direction = DirectionToTop;
while (OCR1A > OcrAtTop); // Warten auf Endstellung
OCR1A = OcrAtTop;
TIMSK1 = 0; // Overflow Interrupt sperren
_delay_ms(100);
TCCR1A = 0;
TCCR1B = 0;
ServoPort &= ~_BV(ServoPowerPin);
DDR(ServoPort) &= ~(_BV(ServoControlPin) | _BV(ServoPowerPin)); // ServoControlPin, ServoPowerPin auf Input
}
Das Atmel-Studio-6-Projekt zum Download: |
Alle Komponenten des Projekts zusammengefasst zum Download:
3D-Dateien für den Arm |
Eagle-Dateien für die Schaltung |
3D-Dateien für das Gehäuse |
Firmware |