Pimp-My-Kicker a.k.a. Wham-O-Meter

5. Software

Ausführlicher möchte ich die erstellte Software beschreiben, hier steckt einiges an Know-How drin. Der Wham-O-Meter v1.2 ist selbstverständlich verfügbar und steht genau wie die Platinen-Files unter der CC 3, nc-by-sa; bitte beachtet dies, bevor ihr ihn in eurem Projekt verwendet. Der Quellcode ist an sich kommentiert und sollte einen guten Einstieg ermöglichen.

5.1 Beschreibung der Messung auf Softwareseite

Zunächst muss eine Unterbrechung der Lichtschranke von Mikrocontroller ausgewertet werden. Es sind dabei 24 Kanäle für 24 Lichtschranken zu berücksichtigen. Am schnellsten funktioniert das, wenn die MCU für jede Lichtschranke einen sogenannten externen Interrupt besitzt und damit nur dann für die Messung arbeitet, wenn dies durch Balleintritt erforderlich ist.

5.1.1 Globale Variablen

Wir benötigen einige globale Variablen für die Zeitmessung; global, damit der Kompiler die Adressen direkt statisch in den ISR ablegen kann und wir nicht viel manuell kopieren müssen.

Globale Variablen
unsigned char oldPortA, oldPortC, oldPortD; // Speichert jeweils die alten PINn-Zustände
double        lastMeasuredSpeed    = 0.0;   // Wird vom Hauptprogramm aus lastMeasuredTicks berechnet
unsigned int  lastMeasuredTicks    = 0;     // Enthält die Anzahl der Timertakte der längsten Unterbrechung nach einer Messung
unsigned int  timerValuesStart[24] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; // Hier werden die Startzeiten in Timerticks, sprich die Beginne von Unterbrechungen abgelegt.
unsigned int  timerValuesEnd[24]   = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; // Hier werden die Endzeiten in Timerticks, sprich die Enden von Unterbrechungen abgelegt.

struct tRecord { // Struct enthält einen Meßwert
  uint16_t timerValue;
  uint8_t  pinA;
  uint8_t  pinC;
  uint8_t  pinD;
} volatile timerValues[50]; //24*2 worst-Case + 2 Sicherheit, spart malloc, 250 Bytes
volatile uint8_t tRecordIndex; // Ist der Index für obiges Array, wird von ISR erhöht und von Hauptprogramm zurückgesetzt.
volatile uint8_t syncVar; // Token für die Messung: 0: Messung bereit/aktiv, 1: Messung beendet, Hauptprogramm kann rechnen.

5.1.2 Pin-Change-Interrupts

Das System für viele externe Interrupts ist beim ATmega644 etwas einfacher gestrickt: für jeden der vier Ports A, B, C und D lassen sich sogenannte Pin-Change-Interrupts (PCINTn) definieren. Sie lösen aus, sobald sich der Pegel an mindestens einem Pin 0-7 des Ports von einer 0 in eine 1 oder umgekehrt ändert („toggle“). Dabei ist jedem Port genau ein Interuptvektor zugeteilt, es existieren also maximal vier unterschiedliche Vektoren und damit maximal vier unterschiedliche Interrupt-Service-Routinen (ISR).

Leider kann man nicht aus der Hardware herauslesen, welcher PIN des Ports die Änderung ausgelöst hat, sondern muss den alten Zustand in einer Variable speichern und bei jedem Auslösen mit dem aktuellen PIN[A-D]-Register vergleichen. Daraus erhält man den jeweiligen Pin.

Neben dem globalen Interrupt-Enable-Flag müssen die PCINT wie folgt initialisiert werden:

Initialisierung der Pin-Change-Interruptgruppen 3,2 und 0
PCICR  = 0b00001101;
PCMSK3 = 0b11111111;
PCMSK2 = 0b11111111;
PCMSK1 = 0b00000000;
PCMSK0 = 0b11111111;

PCMSK1 wird nicht intialisiert, genauso wie Bit 2 im Pin-Change-Interrupt-Control-Register (PCICR). Beide gehören zu Port B, den wir für das LCD und die Tasten verwenden.

Um die Interrupt-Service-Routine möglichst klein zu halten und die eigentlichen Berechnungen nur im Hauptprogramm durchzuführen, werden wir bei jedem Pin-Change-Interrupt die gleiche Routine ausführen und den aktuellen Timer-Wert mit allen drei PIN-Registern zur späteren Auswertung in ein RAM-Array bestehend aus einem Struct speichern.

Sobald einer der 24 Pins seinen Pegel ändert, wird eine der drei folgenden ISR angesprungen:

Interrupt-Service-Routinen für die Messung
ISR(PCINT0_vect) {
	// Keine Messung, wenn main() noch nicht fertig.
	if (syncVar==TIMERSYNC_READY_FOR_CALCULATION) { return; }
	// Nur messen, wenn Timer noch nicht übergelaufen war.
	if (syncVar==TIMERSYNC_READY_FOR_MEASURE) {
		if (!(TCCR1B&0b00000011)) {
			TCCR1B = 0b00000011; // Timer an
			TCNT1  = 0;
		}

		timerValues[tRecordIndex].timerValue = TCNT1;
		timerValues[tRecordIndex].pinA = PINA;
		timerValues[tRecordIndex].pinC = PINC;
		timerValues[tRecordIndex].pinD = PIND;
		if (tRecordIndex<49) { tRecordIndex++; }
	}
	// sicherstellen, dass die Lichtschranken alle wieder geschlossen sind...
	if (!(PINA | PINC | PIND)) {
		_delay_us(50);
		if (!(PINA | PINC | PIND)) {
			TCCR1B = 0b00000000; // Timer aus
			if (syncVar == TIMERSYNC_TIMER_IS_OVERFLOWN) {
				// Software ist so programmiert, dass bei zu geringer Geschwindigkeit keine Messung stattfindet. Threshhold bei 0,3km/h.
				syncVar = TIMERSYNC_READY_FOR_MEASURE;
				lastMeasuredSpeed = 0;
				lastMeasuredTicks = 0;
				clearTimerArrays();
				clearTimerStructs();
			} else {
				syncVar = TIMERSYNC_READY_FOR_CALCULATION;
			}
		}
	}
}
// Auch die beiden anderen ISR auf die obige springen lassen.
ISR(PCINT2_vect, ISR_ALIASOF(PCINT0_vect));
ISR(PCINT3_vect, ISR_ALIASOF(PCINT0_vect));

5.1.3 Zeitmessung

Der ATmega644 besitzt einen 16-bit Timer/Counter (TIMER1), den wir -wie oben im Quelltext ersichtlich- einfach beim ersten Unterbrechen einer Lichtschranke starten und dann laufen lassen. Außerdem erhalten wir durch das Meßsystem in einem RAM-Array die gemessenen Zeiten und PIN?-Zustände. Es gilt nun, diese Daten in fünf Schritten auszuwerten:

  1. Aus einer PIN[A,C,D]-Änderung (0->1) den Anfangszeitpunkt einer Lichtschrankenunterbrechnung bestimmen.
  2. Aus einer PIN[A,C,D]-Änderung (1->0) den Endzeitpunkt einer Lichtschrankenunterbrechnung bestimmen.
  3. Die Zeitdifferenz (=Unterbrechnungszeit) jeder Lichtschranke berechnen
  4. Das Maximum der Zeitdifferenzen finden.
  5. Die Geschwindigkeit berechnen.

Die Rohdaten liegen im Struct-Array timerValues, die Startzeitpunkte werden wir in timerValuesStart schreiben, die Endzeitpunkte in timerValuesEnd. Weiterhin wird das Maximum in lastMeasuredTicks ermittelt werden und die Geschwindigkeit in lastMeasuredSpeed.

Hier der auswertende Code aus der main()-Funktion im Zustand MAIN_SCREEN:

Funktionen Zeitberechnung im Hauptprogramm
// Ok, eine vollständige Messung von den
// Interrupt-Betriebenen Lichtschranken
// Code ist nicht auf Platzersparnis optimiert, das geht natürlich mit Schleifen eleganter.
if (syncVar) {
	clearTimerArrays();

	// Erst einmal die Zeiten berechnen
	oldPortA = 0;
	oldPortC = 0;
	oldPortD = 0;

	for (i=0;i<50;i++) {
		if (  (timerValues[i].pinA&0b00000001) && !(oldPortA&0b00000001) && timerValuesStart[18] == 0) { timerValuesStart[18] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00000001) &&  (oldPortA&0b00000001) && timerValuesEnd[18] == 0)   { timerValuesEnd[18]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b00000010) && !(oldPortA&0b00000010) && timerValuesStart[19] == 0) { timerValuesStart[19] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00000010) &&  (oldPortA&0b00000010) && timerValuesEnd[19] == 0)   { timerValuesEnd[19]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b00000100) && !(oldPortA&0b00000100) && timerValuesStart[17] == 0) { timerValuesStart[17] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00000100) &&  (oldPortA&0b00000100) && timerValuesEnd[17] == 0)   { timerValuesEnd[17]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b00001000) && !(oldPortA&0b00001000) && timerValuesStart[20] == 0) { timerValuesStart[20] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00001000) &&  (oldPortA&0b00001000) && timerValuesEnd[20] == 0)   { timerValuesEnd[20]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b00010000) && !(oldPortA&0b00010000) && timerValuesStart[16] == 0) { timerValuesStart[16] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00010000) &&  (oldPortA&0b00010000) && timerValuesEnd[16] == 0)   { timerValuesEnd[16]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b00100000) && !(oldPortA&0b00100000) && timerValuesStart[21] == 0) { timerValuesStart[21] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b00100000) &&  (oldPortA&0b00100000) && timerValuesEnd[21] == 0)   { timerValuesEnd[21]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b01000000) && !(oldPortA&0b01000000) && timerValuesStart[15] == 0) { timerValuesStart[15] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b01000000) &&  (oldPortA&0b01000000) && timerValuesEnd[15] == 0)   { timerValuesEnd[15]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinA&0b10000000) && !(oldPortA&0b10000000) && timerValuesStart[22] == 0) { timerValuesStart[22] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinA&0b10000000) &&  (oldPortA&0b10000000) && timerValuesEnd[22] == 0)   { timerValuesEnd[22]   = timerValues[i].timerValue; } 

		if (  (timerValues[i].pinC&0b00000001) && !(oldPortC&0b00000001) && timerValuesStart[9] == 0)  { timerValuesStart[9] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00000001) &&  (oldPortC&0b00000001) && timerValuesEnd[9] == 0)    { timerValuesEnd[9]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b00000010) && !(oldPortC&0b00000010) && timerValuesStart[0] == 0)  { timerValuesStart[0] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00000010) &&  (oldPortC&0b00000010) && timerValuesEnd[0] == 0 )   { timerValuesEnd[0]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b00000100) && !(oldPortC&0b00000100) && timerValuesStart[10] == 0) { timerValuesStart[10] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00000100) &&  (oldPortC&0b00000100) && timerValuesEnd[10] == 0)   { timerValuesEnd[10]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b00001000) && !(oldPortC&0b00001000) && timerValuesStart[11] == 0) { timerValuesStart[11] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00001000) &&  (oldPortC&0b00001000) && timerValuesEnd[11] == 0)   { timerValuesEnd[11]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b00010000) && !(oldPortC&0b00010000) && timerValuesStart[12] == 0) { timerValuesStart[12] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00010000) &&  (oldPortC&0b00010000) && timerValuesEnd[12] == 0)   { timerValuesEnd[12]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b00100000) && !(oldPortC&0b00100000) && timerValuesStart[13] == 0) { timerValuesStart[13] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b00100000) &&  (oldPortC&0b00100000) && timerValuesEnd[13] == 0)   { timerValuesEnd[13]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b01000000) && !(oldPortC&0b01000000) && timerValuesStart[23] == 0) { timerValuesStart[23] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b01000000) &&  (oldPortC&0b01000000) && timerValuesEnd[23] == 0)   { timerValuesEnd[23]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinC&0b10000000) && !(oldPortC&0b10000000) && timerValuesStart[14] == 0) { timerValuesStart[14] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinC&0b10000000) &&  (oldPortC&0b10000000) && timerValuesEnd[14] == 0)   { timerValuesEnd[14]   = timerValues[i].timerValue; } 

		if (  (timerValues[i].pinD&0b00000001) && !(oldPortD&0b00000001) && timerValuesStart[5] == 0) { timerValuesStart[5] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00000001) &&  (oldPortD&0b00000001) && timerValuesEnd[5] == 0)   { timerValuesEnd[5]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b00000010) && !(oldPortD&0b00000010) && timerValuesStart[4] == 0) { timerValuesStart[4] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00000010) &&  (oldPortD&0b00000010) && timerValuesEnd[4] == 0)   { timerValuesEnd[4]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b00000100) && !(oldPortD&0b00000100) && timerValuesStart[6] == 0) { timerValuesStart[6] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00000100) &&  (oldPortD&0b00000100) && timerValuesEnd[6] == 0)   { timerValuesEnd[6]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b00001000) && !(oldPortD&0b00001000) && timerValuesStart[3] == 0) { timerValuesStart[3] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00001000) &&  (oldPortD&0b00001000) && timerValuesEnd[3] == 0)   { timerValuesEnd[3]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b00010000) && !(oldPortD&0b00010000) && timerValuesStart[7] == 0) { timerValuesStart[7] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00010000) &&  (oldPortD&0b00010000) && timerValuesEnd[7] == 0)   { timerValuesEnd[7]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b00100000) && !(oldPortD&0b00100000) && timerValuesStart[2] == 0) { timerValuesStart[2] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b00100000) &&  (oldPortD&0b00100000) && timerValuesEnd[2] == 0)   { timerValuesEnd[2]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b01000000) && !(oldPortD&0b01000000) && timerValuesStart[8] == 0) { timerValuesStart[8] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b01000000) &&  (oldPortD&0b01000000) && timerValuesEnd[8] == 0)   { timerValuesEnd[8]   = timerValues[i].timerValue; }
		if (  (timerValues[i].pinD&0b10000000) && !(oldPortD&0b10000000) && timerValuesStart[1] == 0) { timerValuesStart[1] = timerValues[i].timerValue; }
		if ( !(timerValues[i].pinD&0b10000000) &&  (oldPortD&0b10000000) && timerValuesEnd[1] == 0)   { timerValuesEnd[1]   = timerValues[i].timerValue; } 

		// Vergleichswerte sichern
		oldPortA = timerValues[i].pinA;
		oldPortC = timerValues[i].pinC;
		oldPortD = timerValues[i].pinD;

		// Falls weniger als 50 Zustände gespeichert wurden, kann die Schleife hier verlassen werden.
		if (timerValues[i].timerValue==0&&i) { break; }
	}

	// jetzt das Maximum suchen
	for (i=0;i<24;i++) {
		if (timerValuesEnd[i]>timerValuesStart[i]) { currentMaximum = timerValuesEnd[i] - timerValuesStart[i]; }
		if (currentMaximum>lastMeasuredTicks) { lastMeasuredTicks = currentMaximum; }
	}

	// jetzt das Struct-Array clearen
	// Die timerValuesStart und -End bleiben bis zur nächsten Messung erhalten für Debug-Zwecke!
	clearTimerStructs();

	// Hier müßte Sync-Var auf 0 gesetzt werden, wir sperren
	// aber eine neue Messung, bis die Toranzeige vorbei ist, daher
	// erfolgt syncVar=0; erst bei der Anzeige.
}

Benötigt der Ball zu lange, so läuft der Timer über. In diesem Falle haben wir eine ISR, die keine Zeit berechnet, alle Variablen zurücksetzt und den Timer ausschaltet.

Timer-Überlauf
ISR(TIMER1_OVF_vect) {
	// In der ISR muss der Timer ausgeschaltet werden.
	TCCR1B = 0b00000000; // Timer aus
	TCNT1  = 0;
	lastMeasuredSpeed = 0;
	lastMeasuredTicks = 0;
	clearTimerArrays();
	clearTimerStructs();
	syncVar = 0;
}

Wir können nun berechnen, welche minimale und theoretisch maximale Geschwindigkeit unser Equipment messen kann. Die Zeit, die ein Timertick in Sekunden benötigt, ist:

  • \text{Taktfrequenz} f_\text{CPU} = 10000000\frac{1}{\text{s}}
  • t_\text{timerzylkus} = \frac{prescaler}{f_\text{CPU}}=\frac{64}{10000000\frac{1}{\text{s}}} =0{,}0000064\text{s}

Mit einem angenommenen Balldurchmesser von 0,034 Metern (34mm) errechnet sich die minimale Geschwindigkeit bei gemessenen 65535 Timerzylen zu:

  • v_\text{min} = \frac{\text{s}}{65535 \cdot t_\text{timerzylkus}}=\frac{0{,}034m}{65535 \cdot 0{,}0000064\text{s}} \simeq 0{,}081 \frac{\text{m}}{\text{s}} = 0{,}2916 \frac{\text{km}}{\text{h}}

und die maximale Geschwindigkeit mit nur einem Timerzyklus zu:

  • v_\text{max} = \frac{\text{s}}{1 \cdot t_\text{timerzylkus}}=\frac{0{,}034m}{1 \cdot 0{,}0000064\text{s}} = 5312{,}5 \frac{\text{m}}{\text{s}} = 19125 \frac{\text{km}}{\text{h}}

Das sollte wohl reichen…

5.1.4 Messablauf

Trifft also ein Ball auf’s Tor, so wird die erste Lichtschranke unterbrochen und die ISR angesprungen. Dort wird der Timer aktiviert und zurückgesetzt.

Nun wird für jede weitere Änderung der Lichtschranken der Timerwert und die neuen PIN-Zustände in timerValues gespeichert, der allererste Zählerwert wird dabei immer 0 sein. Ob ein Interrupt eine Unterbrechnung oder Wiederherstellung darstellt, wissen wir noch nicht.

Bewegt sich der Ball nun aus dem Detektorfeld heraus, so werden die Lichtschranken nach und nach wiederhergestellt. Ist keine mehr unterbrochen, so wird der Timer abgeschaltet und dem Hauptprogramm das Token zum Berechnen der Zeiten übergeben. Es ermittelt das Maximum der gemessenen Unterbrechungszeitenin der Variable lastMeasuredTicks und stellt die gemessene Geschwindigkeit auf dem Display dar.

5.2 Grafisches LCD

Wenden wir uns dem grafischen LCD zu. Das DOGM der Firma Electronic Assembly in Lochham ist ein ziemlich komfortables Bastel-LCD, da es für das eigentliche Anzeigemodul bereits einen Treiber und einen Displaycontroller mitbringt, der auch noch mit minimaler externer Beschaltung die nötige Kontrastspannung selbst erzeugen kann. Dennoch ergeben sich bauartbedingt ein paar Besonderheiten:

  • Das LCD muß (!) nach Anlegen der Versorgungsspannung einmal auf seinem Hardware-Reset-Pin zurückgesetzt werden.
  • Es kann nur beschrieben, nicht aber gelesen werden. Ein Video-Ram ist für die komfortable Programmierung erforderlich.
  • Schriftarten und Zeichensätze sucht man vergebens, der Controller kann nur einzelne Bits und Bytes darstellen. Man muss die Routinen zur Schriftdarstellung und das Erzeugen von Zeichensätzen selbst in die Hand nehmen.
  • Daten werden über SPI seriell und über ein 9. Bit (GPIO) an den Controller gesendet.

Ich habe mir eine eigene Bibliothek geschrieben, die mal generisch für Grafik-LCDs ausgebaut werden kann, im Moment aber nur das DOGM132 unterstützt.

5.2.1 Initialisierung

Zunächst müssen MCU- und LCD-Hardware initialisiert werden. Für das DOGM ist die SPI-Schnittstelle wie folgt zu konfigurieren, es sind nur die relevanten Bits beschrieben:

SPI-Control-Register (SPCR)
// Bit 6 - SPE:  SPI-Transceiver aktiviert
// Bit 5 - DORD: Data Order auf "Most Significant Bit transmitted first"
// Bit 4 - MSTR: SPI ist im Master-Mode
// Bit 3 - CPOL: Clock Polarity auf 0-1-0 bei Impuls, also "idle-low"
// Bit 2-0: SPI-Taktrate auf fcpu/4
SPCR=0b01010000;
SPI-Status-Register (SPSR)
// Bit 0: Kein Double-Speed-Modus
SPSR=0x00;

Einmalig muss das LCD zurückgesetzt werden, es schließt sich eine Reihe von Initialisierungsbefehlen an, die aus dem Datenblatt übernommen wurden.

LCD-Initialisierung
	glcd_hardware_reset();
	glcd_prepare_command();
	spi(0x40); spi(0xA1); spi(0xC0); spi(0xA6);
	spi(0xA2); spi(0x2F); spi(0xF8); spi(0x00);
	spi(0x23); spi(0x81); spi(0x1F); spi(0xAC);
	spi(0x00); spi(0xAF);

5.2.2 Header-File

Das Header-File bietet eine Reihe von Grafikfunktionen, die alle ausschließlich auf dem Video-RAM arbeiten:

glcd.h
// Die Lib unterstützt im Prinzip auch das DOGM128.
// Dafür hier einfach die neuen Pixeldimensionen eintragen.
#define GLCD_WIDTH  132 // Breite in Pixel
#define GLCD_HEIGHT  32 // Höhe in
#define A0_PORT   PORTB
#define A0_PIN        4
#define glcd_prepare_command() A0_PORT&=~(1<<A0_PIN)
#define glcd_prepare_data()    A0_PORT|=(1<<A0_PIN)
#define PROP_FONT_HEADER_SIZE 6
#define COLOR_WHITE   0
#define COLOR_BLACK   1

// LCD-Variablen
#if   GLCD_HEIGHT>64
	#error GLCD_HEIGHT bigger than 64 not yet supported.
#elif GLCD_HEIGHT>32
	#define GLCD_VRAM_TYPE uint64_t
#elif GLCD_HEIGHT>16
	#define GLCD_VRAM_TYPE uint32_t
#elif GLCD_HEIGHT>8
	#define GLCD_VRAM_TYPE uint16_t
#elif GLCD_HEIGHT>1
	#define GLCD_VRAM_TYPE uint8_t
#else
	#error GLCD_HEIGHT cannot be smaller than 1.
#endif

GLCD_VRAM_TYPE vram[GLCD_WIDTH]; // Video-RAM
char           vBuffer[30];      // Display-Buffer für sprintf();

// LCD-Functions
void glcd_hardware_reset();
void glcd_clear();
void glcd_contrast(unsigned char contrast);
void glcd_repaint();

// alle Funktionen arbeiten im VideoRAM: um Änderungen sichtbar
// zu machen, muss "glcd_repaint();" aufgerufen werden.
void glcd_setpixel(unsigned char x, unsigned char y);
void glcd_linehorizontal(unsigned char x1, unsigned char y1, unsigned char x2);
void glcd_linevertical(unsigned char x1, unsigned char y1, unsigned char y2);
void glcd_rectangle(unsigned char x1, unsigned char y1, unsigned char x2, unsigned char y2);
void glcd_line(unsigned char x1, unsigned char y1, unsigned char x2, unsigned char y2);
void glcd_puts(int x, int y, char *text, PGM_P font, char additionalSpacing, unsigned char color);
void glcd_image(int x, int y, PGM_P image);
void glcd_filledrectangle(unsigned char x1, unsigned char y1, unsigned char x2, unsigned char y2, unsigned char color);
void spi(unsigned char myByte);

5.2.3 Schriftarten

Zeichensätze besitzen die DOGM-Serien nicht, man kann immer nur einzelne Pixel in Byte-Gruppen zusammengefasst setzen oder löschen. Es sind somit für jede gewünschte Schriftart eigene Bytemuster zu generieren, die das jeweilige Zeichen darstellen.

Es existiert dafür der GLCD-FontCreator (Version 2.1), ein Java-Programm, das aus Systemschriftarten für avr-gcc entsprechende Headerfiles erzeugt. Für eine Schriftart sind damit mehrere Optionen einstellbar:

  • Startzeichen (standart 0x20): Ab welchem Zeichen sollen Bitmuster erzeugt werden? Es wird automatisch ein Offset mit in die Schriftart integriert, sodass man im C-Quelltext ganz normale Strings verwenden kann („Hallo Welt!“) und nicht selbst von jedem gewünschten Zeichen noch den Hexwert des Startzeichens abziehen muss.
  • Endezeichen: Das letzte zu erzeugende Bitmuster.
  • Schriftart: Welche (TrueType oder OpenType) Schriftart soll abgebildet werden?
  • Schriftgröße: Welche Größe in Point (pt) soll erzeugt werden? Leider haben manche Schriftarten oben wegen einzelner Zeichen bei allen Buchstaben Versatz; man muss sie dann mit negativen Koordianten platzieren. Ist ein bißchen Try-and-Error.
  • Für große Schriftarten kann zusätzlicher Abstand eingegeben werden, sodass z.B. 3 statt nur einem Pixel horizontaler Abstand zwischen zwei Zeichen besteht.

Der Aufbau einer Schriftart ist dann wie folgt:

arial12.h
// Hier muss wegen "differ in signedness" Warnungen
// statt "unsigned char" der Typ "uint8_t" geschrieben werden
static uint8_t arial12[] PROGMEM = {
    0x09, 0x9C, // size
    0x0A, // Breite in Pixeln
    0x0E, // Höhe in Pixel, ==> 2 Bytes pro Spalte
    0x20, // Erstes Zeichen (Offset)
    0xE0, // Anzahl der Zeichen

    // Zeichenbreiten für dynamische Schriftarten
    0x03, 0x01, // [...]

    // Eigentliche Bitmuster (S1Z1, S1Z2, S2Z1, S2Z2, S3Z1, S3Z2, usw.)
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 32
    0xF8, 0x14, // 33
    // [...]
}

Es muss dann nur noch glcd_puts() aufgerufen werden, mit einem Pointer auf vBuffer, das den Text enthält und einem PGM_P-Pointer auf die jeweilige Schriftart. Leider hat der FontCreator einen Bug und erstellt die Schriftarten bei mehr als einem Byte pro Spalte nicht richtig. Die Funktion glcd_puts() korrigiert diesen Fehler.

5.3 Button-Behandlung

Die Knöpfe sind relativ schnell erklärt. Wir haben nur zwei Anforderungen:

  • Sie müssen entprellt werden. Ein Druck darauf soll von der Software auch nur als solcher gewertet werden.
  • Ich möchte verschieden lange Drücke unterscheiden können (kurz, mittel und lang), damit bei Zahlenwertänderungen durch längeres Drücken der jeweiligen Knöpfe der Wert schneller geändert werden kann (z.B. +-1, +-10, +-50).

Dafür spendiere ich jedem Knopf eine Zählervariable, die repräsentativ die gedrückte Zeit wiederspiegelt. Ist ein Knopf nicht gedrückt, so ist die entsprechende Variable 0.

Knopfzähler-Variablen und -Definitionen
// Knopf-Behandlung
#define UPBTN !(PINB&0b00000010)
#define DNBTN !(PINB&0b00000001)
#define OKBTN !(PINB&0b00000010)&&!(PINB&0b00000001)
unsigned char okBtn;
unsigned char dnBtn;
unsigned char upBtn;

Jetzt wird innerhalb der Hauptschleife der Zustand des entsprechenden Portpins abgefragt. Ist er 0 (Taster schaltet gegen Masse!), so gilt der Knopf als gedrückt und der Knopfzähler wird inkrementiert. Eine Besonderheit ergibt sich noch beim OK-Button, der über zwei Pins angeschlossen ist. Die gesamte Logik, die bei jedem Durchlauf der Hauptschleife einmal aufgerufen werden muss, lautet dann:

Knopfzähler-Erfassung
	if (OKBTN) {
		if (okBtn < 50) { okBtn++; }
		dnBtn=0;
		upBtn=0;
	} else if (DNBTN) {
		if (dnBtn < 50) { dnBtn++; }
		okBtn=0;
		upBtn=0;
	} else if (UPBTN) {
		if (upBtn < 50) { upBtn++; }
		okBtn=0;
		dnBtn=0;
	} else {
		okBtn=0;
		dnBtn=0;
		upBtn=0;
	}

Sehr einfach kann ich nun abfragen:

  • ob der Knopf erstmalig gedrückt wurde (z.B. dnBtn==1). Das wird nur einmal in der gesamten Hauptschleife der Fall sein, beim nächsten mal ist es schon 2.
  • ob der Knopf ein bisschen länger gedrückt wurde (dnBtn>10 && dnBtn<50)
  • ob er sehr lange gedrückt wurde (dnBtn==50)

Natürlich läßt sich die exakte Zeit nicht angeben, da viele Hauptschleifen-Zustände unterschiedlich lange benötigen. Es fällt im praktischen Betrieb aber nicht auf.

5.4 Mehrsprachigkeit

Ein schönes, wenn auch ein „Nur-weil-ich-kann“-Feature ist die durchgängige Mehrsprachigkeit des Systems. Jeder Text auf dem Display hat ein Pendant auf Englisch und kann in Echtzeit gewechselt werden. Zwar könnte man das Speicherlayout als einen großen String realisieren und sich immer von 0-Byte zu 0-Byte durchhangeln, da wir aber genug Platz im ROM haben, gefiel mir die Variante mit den mehrdimensionalen Arrays besser. Wie wir später noch sehen werden, hat das den Nachteil, dass die Dimensionen manuell übergeben werden müssen.

Ein mehrsprachiger String im Flash-ROM hat damit diesen Aufbau:

Mehrsprachiger String
char myString[2][13] PROGMEM = { "Hallo Welt!", "Hello World!" };

Die erste Dimension ist folglich die der Sprachen, die zweite Dimension, die bei variablem Inhalt als Maximum der Stringlängen +1 für das abschließende 0-Byte angegeben werden muss, speichert die Länge der Inhalte. PROGMEM weist avrgcc an, die Variable ausschließlich im Flash-ROM abzulegen, und nicht zusätzlich im RAM.

Speichert man die aktuell ausgewählte Sprache in einem globalen uint8_t:

Globale Sprache
uint8_t currentLanguage=0;

so läßt sich sehr schön auf den Inhalt der Variablen zugreifen:

Zugriff auf einen Flash-String
// void glcd_drawMenu(PGM_P headerText);
glcd_drawMenu(myString[currentLanguage]);

Der Compiler errechnet automatisch die Startadresse von MyString an der richtigen Sprachstelle anhand der Variable currentLanguage und der absoluten Startadresse von myString im Flash-ROM.

Auch das Behandeln von zweidimensionalen Strings für Menüs verläuft ähnlich:

Mehrdimensionaler und -sprachiger String
char myMenuString[2][3][13] PROGMEM = {
	{"DE-Test1", "DE-Test2", "DE-Test3" },
	{"EN-Test1", "EN-Test2", "EN-Test3" },
};
// Würde mit currentLanguage=1 "EN-Test1" ergeben
glcd_drawMenu(myMenuString[currentLanguage][0]);

5.5 Serielle Kommunikation

Die beiden Einheiten sollen zwecks synchroner Displayanzeige auch miteinander kommunizieren können. Unterschiedliche Events wie z.B. Tore beim Gegner müssen auf beiden LCDs dargestellt werden, chic wäre auch die Übertragung von Konfigurationseinstellungen.

5.5.1 Ursprünglicher Plan

Am Anfang hatte ich vorgesehen, den Hardware-UART beider Einheiten über Kreuz zu verbinden und einfach so Nachrichten auszutauschen. Die einzige Schwierigkeit bestand darin, dass so auch zwei Lichtschranken jeweils miteinander verbunden sind, und sich partout nicht asynchron unterbrechen ließen: War die eine unterbrochen, so wurde durch das Licht auf der anderen Seite die Leitung trotzdem noch ausreichend gegen Masse geschaltet, sodass beide MCUs keine Unterbrechung registrierten.

Die Kommunikation hingegen hatte einigermaßen funktioniert, Nachrichtentechniker wissen ja mit AWGN-Kanälen umzugehen, nicht wahr. Fehlerkorrektur und zusätzliche Redundanz wären hier die Stichwörter.

Eine separate Leitung („IRQ“) hätte beiden MCUs signalisiert, wann sie ihre Receiver einschalten sollen, und wann es regulärer Lichtschrankenbetrieb ist.

5.5.2 Verbesserung

Das ist aber dann nicht Made-in-Germany, deswegen galt es zu überlegen, wie das System verbessert werden kann:

  • Full-Duplex ist nicht erforderlich: Eine Einheit reagiert immer nur auf die Nachrichten, die Verarbeitungszeit dazwischen erlaubt einen Betrieb der Leitung auch im Halbduplex.
  • Aufwand der Fehlerkorrektur: Kann ich die Fehlerrate des Kanals drücken, so muß ich keinen Aufwand und keine Ressourcen mehr für Fehlererkenung und -Korrektur spendieren; die MCU hat Zeit für andere Dinge.

Auf den entscheidenden Punkt brachte es dann die Application Note 274 von Atmel, die da lautet „Single-wire Software UART“. Über eine Leitung scheint die Kommunikation sogar Interrupt-gesteuert möglich zu sein. Nach etwas Einlesen waren schnell zwei Einschränkungen gefunden:

  1. Es ist die Leitung auf beiden Seiten an einen vollwertigen externen (EINT[0-2]) Interrupt anzuschließen. Dieser startet den Empfang.
  2. Ein freier 8bit-Timer ist ebenso erforderlich.

Der Ablauf der Kommunikation gestaltet sich dann denkbar einfach:

  • Sender generiert Start-Bit und startet Timer
  • Empfänger feuert EINTn ISR und startet Timer plus x µ-Sekunden (76 Cycles), damit er die Mitte trifft zum Samplen
  • Sender legt nun in seiner Timer-ISR Bit für Bit auf die Leitung (EINT deaktiviert)
  • Empfänger tastet in seiner Timer-ISR (+76 Cycles!) die Leitung ab und empfängt so Bit für Bit.
  • Nach jeweils 1 Bit Start + 8 Bit Daten +  1 Bit Stop ist das Datum empfangen; es kann optional eine Art Software-ISR angesprungen werden.
  • Beide Einheiten aktivieren wieder ihren EINTn…

5.5.3 Änderungen im Header-File

Nachdem Atmel die Workbench zum Programmieren benutzt, mußte ich im Headerfile nur die Bezeichnungen der Register ändern auf die des ATmega644 und durch die Namen vom avrgcc ersetzen. Außerdem schreibe ich das fertig empfangene Byte direkt in den RX-Buffer, damit das Hauptprogramm dieses auswerten kann. Die relevanten Ausschnitte hier nachfolgend.

single_wire_UART.h - Änderungen im Headerfile
/* Port and pin settings. */
#define SW_UART_PIN_NUMBER    2       //!< Set pin number for communication.
#define SW_UART_PORT          PORTB   //!< Set port for communication.
#define SW_UART_PIN           PINB    //!< Set pin for communication.
#define SW_UART_DDR           DDRB    //!< Data direction register. Not available for high voltage ports.

/* UART interrupt vectors definitions. */
#define SW_UART_EXTERNAL_INTERRUPT_VECTOR       INT2_vect             //!< UART external interrupt vector. Make sure this is in accordance to the defined UART pin.
#define SW_UART_TIMER_COMPARE_INTERRUPT_VECTOR  TIMER0_COMPA_vect      //!< UART compare interrupt vector.

/* Timer macros. These are device dependent. */
#define CLEAR_UART_TIMER_ON_COMPARE_MATCH()     (TCCR0A |= (1<< Set timer control register to clear timer on compare match (CTC).
#define SET_UART_TIMER_COMPARE_WAIT_ONE()       (OCR0A   = WAIT_ONE)                                 //!< Sets the timer compare register to one period.
#define SET_UART_TIMER_COMPARE_START_TRANSMIT() (OCR0A   = WAIT_ONE - (TRANSMIT_DELAY/PRESCALER))    //!< Sets the timer compare register to the correct value when a transmission is started.
#define SET_UART_TIMER_COMPARE_START_RECEIVE()  (OCR0A   = WAIT_ONEHALF - (RECEIVE_DELAY/PRESCALER)) //!< Sets the timer compare register to the correct value when a reception is started.
#define CLEAR_UART_TIMER()                      (TCNT0   = 0x00)
#define ENABLE_UART_TIMER_INTERRUPT()           (TIMSK0 |=  (1<< Sets falling edge of INT0 generates interrupt.
#define ENABLE_UART_EXTERNAL_INTERRUPT()        (EIMSK |=  (1<

5.5.4 Hier Ausschnitte aus dem C-File

single_wire_UART.c - Änderungen im C-File
// Ca. ab Zeile 230
// [...]
	UART_Rx_buffer = UART_Rx_data;
	SET_FLAG( SW_UART_status, SW_UART_RX_BUFFER_FULL );
	CLEAR_UART_EXTERNAL_INTERRUPT_FLAG();
	ENABLE_UART_EXTERNAL_INTERRUPT();   //Get ready to receive new byte.
	// hier hinzugefügt, dass der RX-Buffer automatisch befüllt wird.
	rxBuffer[rxBufferIndex] = SW_UART_Receive();
	if (rxBufferIndex<29) { rxBufferIndex++; }
// [...]

// Hinzugefügt werden noch zwei Funktionen
// Erwartet einen String als Parameter. Kommt im String 0x00 als Zeichen vor, so kann
// optional der txBufferIndex auf die Zahl der zu übertragenden Zeichen gesetzt werden.
void uart_puts (char *s) {
	unsigned char i;
	i=txBufferIndex;
	while (*s || i>0) {
		SW_UART_Transmit(*s);
		while( READ_FLAG(SW_UART_status, SW_UART_TX_BUFFER_FULL) );
		s++;
		if (i>0) { i--; }
	}
	SW_UART_Transmit(59);
	while( READ_FLAG(SW_UART_status, SW_UART_TX_BUFFER_FULL) );
}

// Leert den Empfangsbuffer
void clearUsartRxBuffer() {
	unsigned char i;
	for (i=0;i<30;i++) { rxBuffer[i] = 0; }
	rxBufferIndex = 0;
}

Zum Senden eines Strings wird uart_puts() mit dem entsprechenden String synchron aufgerufen. Als Besonderheit sei hier angemerkt, dass meine Nachrichten alle mit dem Semikolon (Zeichen 59) abgeschlossen werden. Möchtet ihr die Lib auch einsetzen, solltet ihr oben das zusätzliche SW_UART_Transmit(59) und das nachfolgende while() entfernen.

5.5.5 Nachrichten

Zu guter Letzt noch die Nachrichten, die übertragen werden können:

Auszug aus flash_strings.h - Serielle Nachrichten
// Neues Spiel - Nachrichten
char newGameRequest[5]    PROGMEM = { "NGRQ" }; // Anfrage für ein neues Spiel an andere Unit
char newGameCancel[5]     PROGMEM = { "NGCX" }; // Abgelehnt oder Request abgebrochen
char newGameConfirm[5]    PROGMEM = { "NGOF" }; // Neues Spiel von Gegenseite bestätigt

// Tore - Nachrichten
char goalOpponentHit[5]   PROGMEM = { "GOHT" }; // "Treffer kassiert" -> andere Unit
char goalCanceled[5]      PROGMEM = { "GOCX" }; // Wir haben unser Tor rückgängig gemacht: Andere Unit dekrementiere Gegnerzähler.
char goalManuallyAdded[5] PROGMEM = { "GOMA" }; // Wir haben Gegner ein Tor gutgeschrieben. Andere Unit inkrementiere Eigenzähler.

// Sende-Config - Nachrichten
char sendVictoryConfig[5]          PROGMEM = { "TXVG" }; // Übertrage Siegbedingungen TXVG[4Bytes:Eigene Tore, Gegn. Tore, Eigene führende Tore, Gegn. f. Tore], startet neues Spiel
char sendHandicapConfigMaxSpeed[5] PROGMEM = { "TXIS" }; // Übertrage minimalen Speed TXIS[4 Bytes Float Value]
char sendHandicapConfigMinSpeed[5] PROGMEM = { "TXAS" }; // Übertrage maximalen Speed TXIS[4 Bytes Float Value]

5.6 Hauptprogramm und State-Machine

Bleibt nur noch eines zu erledigen; nämlich das Hauptprogramm zu erläutern. Es besteht im Wesentlichen aus vier Teilen, die jetzt im einzelnen beschrieben werden.

5.6.1 EEPROM-Layout

Im EEPROM werden Konfiguration und High-Score-Liste gespeichert, wir belegen von den 4096 möglichen Bytes 98, und damit etwa 4% des verfügbaren Platzes. Er ist aufgeteilt in:

EEPROM-Layout
double        ballDiameterEeprom          EEMEM = 0.034; // Balldurchmesser
unsigned char lcdContrastEeprom           EEMEM = 32;    // LCD-Kontrast
unsigned char currentLanguageEeprom       EEMEM = 1;     // Spracheinstellung
unsigned char gameOwnLeadingGoalsEeprom   EEMEM = 1;
unsigned char gameOppLeadingGoalsEeprom   EEMEM = 1;
unsigned char gameVictoryOwnGoalsEeprom   EEMEM = 5;
unsigned char gameVictoryOppGoalsEeprom   EEMEM = 5;
double        gameHandicapMinSpeedEeprom  EEMEM = 1.0;
double        gameHandicapMaxSpeedEeprom  EEMEM = 100.0;
struct top10record {   // All-Time-Records der Schussgeschwindigkeiten
   uint8_t kuerzel[4]; // 3 Zeichen für Namen, 1x Nullbyte
   double   speed;     // 4 Byte für Geschwindigkeit in km/h, Initialisieren auf "   " und 0.0
} top10array[10] EEMEM = {
	{ "   ", 0.0 }, { "   ", 0.0 },
	{ "   ", 0.0 }, { "   ", 0.0 },
	{ "   ", 0.0 }, { "   ", 0.0 },
	{ "   ", 0.0 }, { "   ", 0.0 },
	{ "   ", 0.0 }, { "   ", 0.0 }
};

5.6.2 Globale Variablen

Auch bei den globalen Variablen sollte es wenig Verständnisschwierigkeiten geben:

EEPROM-Layout
// Knopf-Behandlung, bereits erwähnte Zähler für Zeit eines Tastendruckes
unsigned char okBtn;
unsigned char dnBtn;
unsigned char upBtn;

// Timer-Variablen
unsigned char oldPortA, oldPortC, oldPortD; // Speichert die alten PIN-Zustände
unsigned char runningTimers    = 0; // Wieviele Lichtschranken sind aktuell unterbrochen?
unsigned char runningTimersMax = 0; // Wieviele waren maximal unterbrochen?
double        lastMeasuredSpeed    = 0.0; // Was war die letzte Geschwindigkeit in KMH?
unsigned int  lastMeasuredTicks    = 0;   // Wievielen Timerticks hat das entsprochen?
double        lastShotSpeeds [4]   = {0,0,0,0}; // Die Historie der Schußgeschwindigkeiten
unsigned int  timerValuesStart[24] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; // Nimmt für die jeweils unterbrochene Lichtschranke den Startwert des Zählers
unsigned int  timerValuesEnd[24]   = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; // Nimmt für die jeweils unterbrochene Lichtschranke den Endwert des Zählers

// Menüzustände
unsigned char currentScreenState  = 0;   // Zähler / Zustand für die Bildschirm-Statemachine
unsigned char currentMenuState    = 0;   // gerade ausgewählter Menüzustand
unsigned char currentMenuStateOld = 0;   // vorheiger Zustand für die Animation
unsigned char currentLanguage     = 0;   // ID der Sprache (0=DE,1=EN)
unsigned char currentContrast     = 0;   // Kontrast des LCD
double        currentBallDiameter = 0.0; // Balldurchmesser
unsigned char eventSource         = FROM_MAINMENU; // Speichert, von aus aus bestimmte Zustände der Hauptschleife aufgerufen wurden, also durch Nachricht der seriellen Schnittstelle oder durch Knopfdruck. Der jeweilige Zustand kann dann anders reagieren.

// Spielvariablen
unsigned char gameVictoryOwnGoals;  // Eigene Siegtore
unsigned char gameVictoryOppGoals;  // Siegtore des Gegners
unsigned char gameOwnLeadingGoals;  // dito für führende Tore
unsigned char gameOppLeadingGoals;  // dito
double        gameHandicapMinSpeed; // minimaler Speed für das eigene Tor
double        gameHandicapMaxSpeed; // maximaler Speed
unsigned char gameRunning = 0;      // Läuft gerade ein Spiel (=1)? Zustand im Hauptbildschirm.
unsigned char gameOppGoals;         // Zähler der eigenen Tore
unsigned char gameOwnGoals;         // Zähler der gegnerischen Tore

5.6.3 Interrupt-Service-Routinen

Die Beschreibung der Messung wurde an voriger Stelle bereits vorgenommen, ich spare mir eine genauere Erklärung hier. Auch der Sourcecode ist ausführlich dokumentiert.

5.6.4 Hilfsfunktionen

Es gibt ein paar kleine Hilfsfunktionen, die das Leben erleichtern, aber speziell für das Kickerprojekt geschrieben wurden und deswegen nicht in den Bibliotheken Platz finden.

  • void handleLightrayInterrupted(unsigned char whichLightray): Schon erklärte Lichtschrankenbehandlung
  • void handleLightrayRestored(unsigned char whichLightray): Schon erklärte Lichtschrankenbehandlung
  • void glcd_drawMenuFrame(PGM_P headerText): Zeichnet den Menürahmen mit angegebener Überschrift
  • void glcd_renderMenu(PGM_P menuHeader, PGM_P menuItems, unsigned char menuItemCount, unsigned char dimension, unsigned char menustate, unsigned char old_menustate): Zeichnet angegebenes Menü mit Überschrift und animiert, wenn sich die letzten beiden Variablen unterscheiden.
  • void clearTimerArrays(): Löscht die timerValue*-Arrays
  • signed char strpos_P(char *haystack, PGM_P needle): wie strpos, nur mit needle im Flash-ROM
  • unsigned char isTop10Record(double *valueToTest): Testet, ob eine übergebene Geschwindigkeit für einen TOP10-Platz berechtigt ist.
  • void newGame(): Startet ein neues Spiel und setzt die Variablen zurück-

5.6.5 Initialisierung

Nicht viel zu schreiben hier. Nach ein paar lokalen Variablen für das Hauptprogramm folgt die Initialisierung der Ports, der Timer, des Software-UARTs und des LCDs.

Init
	// Interrupts für Lichtschranken
	DDRA  = 0b00000000; // Alles inputs
	PORTA = 0b11111111; // PULLUPS an

	// Serielle Schnittstelle und Taster.
	DDRB  = 0b11111000;
	PORTB = 0b00000111;

	// Interrupts für Lichtschranken
	DDRC  = 0b00000000; // Alles inputs
	PORTC = 0b11111111; // PULLUPS an

	// Interrupts für Lichtschranken
	DDRD  = 0b00000000; // Alles inputs
	PORTD = 0b11111111; // PULLUPS an

	// PinChange Interrups konfigurieren
	EICRA = 0b00000000; // Tasten werden gepollt, die serielle Schnittstelle konfiguriert die Pins selbst
	EIMSK = 0b00000000;

	PCICR = 0b00001101;  // PCINT-Gruppen 3,2 und 0 enabled. 1 nicht, da für SPI benötigt.
	PCMSK3 = 0b11111111;
	PCMSK2 = 0b11111111;
	PCMSK1 = 0b00000000;
	PCMSK0 = 0b11111111;

	// TIMER 1 (16 bit konfigurieren)
	TCCR1A = 0b00000000; // Normal Operation, OCR1 Outputs disconnected
	TCCR1B = 0b00000000; // Timer aus (0b00000101 für F_CPU/1024 als Prescaler)
	TCCR1C = 0b00000000; // Kein Force Prescaler
	TCNT1  = 0; //Timer auf 0 setzen.

	TIMSK1 = 0b00000001; // TC1: Overflow-Interrupt einschalten

	// UART INIT
	SW_UART_Enable();

	// SPI konfigurieren für DOGM-Interaktion
	SPCR=0b01010000;
	SPSR=0x00;

Darauf kommt der Splashscreen, und das Laden der Konfiguration aus dem EEPROM. Weiterhin wird die Bildschirm-Statemachine auf den Hauptbildschirm initialisiert.

5.6.6 State-Machine

Generell ist es bei der Software so, dass sie nach der Initialisierung in einer großen Schleife, tatsächlich mit while (1) {…} programmiert, abläuft.

5.6.6.1 Leeren des Display-VRAMs

Damit alle Bildschirme auf einen leeren weißen Bildschirm zeichnen können, wird der VRAM am Anfang der Schleife geleert.

LCD-VRAM Clear
while (1) {
	// Buttons hier
	// Seriell hier
	glcd_clear();
	// State-Machine hier
	// glcd_repaint() hier
}

5.6.6.2 Verarbeiten der seriellen Schnittstelle

Nun wird die serielle Schnittstelle lesend bedient, es wird also anhand des asynchron im Interrupt-Betrieb befüllten rxBuffer’s eine auszuführende Aktion festgelegt, sofern der Buffer eine entsprechende Anweisung enthält. Die Aktion kann beispielsweise sein, in den Bildschirm mit der Tor-Animation zu wechseln.

Serielle Schnittstelle
while (1) {
	// [...]
	// Mögliche Nachrichten mit 5 Zeichen: "NGRQ;", "NGCX;", "NGOF;", "GOHT;", "GOCX;", "GOMA;"
	if (strpos_P(rxBuffer, newGameRequest)==0) {
		currentScreenState = NEW_GAME_REQUEST; // Schalte in neuen Screen
		clearUsartRxBuffer(); // Leere Buffer, damit beim nächsten Hauptschleifendurchlauf nicht erneut dieser Punkt hier angesprungen wird.
	}
	// So werden alle 5-Bytigen Nachrichten behandelt.

	// Mögliche Nachrichten mit 9 Zeichen: "TXVG[4B];", "TXIS[4B];", "TXAS[4B];"
	// Hier geht das nicht so einfach, wir müssen noch die 4 "Nutzbytes" abwarten.
	// rxBufferIndex muss bei 9 stehen, da 0-8 von der Nachricht belegt sind.
	if (strpos_P(rxBuffer, sendVictoryConfig)==0 && rxBufferIndex==9) {
		// Übernehme und speichere die empfangene Konfiguration.
		// Jetzt wie gehabt
		clearUsartRxBuffer(); // Leere Buffer, damit beim nächsten Hauptschleifendurchlauf nicht erneut dieser Punkt hier angesprungen wird.
	}
	// [...]
}

Zum Senden steht jedem Zustand die Funktion uart_puts() zur Verfügung. Sie erwartet einen Pointer auf den RAM-Buffer mit dem zu sendenden Text; sofern der Text 0-Bytes enthält, muss zusätzlich txBufferIndex manuell auf die richtige Länge gesetzt werden. Dadurch wird vollständiges Übertragen gewährleistet.

5.6.6.3 Zustände

Es folgen alle Zustände und Bildschirme, die irgendwie auf dem LCD angezeigt werden können. Ein Zustand ist dabei immer gleich aufgebaut.

  • Zuerst wird das Layout gezeichnet, z.B. mit glcd_drawMenuFrame().
  • Dann werden Berechnungen auf lokalen oder globalen Variablen durchgeführt.
  • Die Ergebnisse werden mit sprint_f() in einen Buffer geschrieben und mit glcd_puts() ebenfalls ausgeben.
  • Abschließend erfolgt das Prüfen der Buttons, wobei mit <UP>/<DOWN> meist eine lokale Variable (z.B. die aktuelle Menüposition) geändert und mit <OK> eine Aktion ausgelöst wird.

Möchte man beispielsweise von einem Zustand in einen anderen springen, so wäre ein schneller C-Code dafür:

State-Jumping
while (1) {
	switch (currentScreenState) {
		// Das ist der erste Zustand, hier ohne ENUM.
		case 0:
			sprint_f(vBuffer, "Zustand 1");
			glcd_puts(1,1,vBuffer,Arial10Regular,COLOR_BLACK);
			if (okBtn==50) {
				okBtn = 51;
				currentScreenState = 1;
			}
		break;
		// Das ist der zweite Zustand, ohne ENUM.
		case 1:
			sprint_f(vBuffer, "Zustand 2");
			glcd_puts(1,1,vBuffer,Arial10Regular,COLOR_BLACK);
			if (okBtn==1) {
				okBtn = 51;
				currentScreenState = 0;
			}
		break;
	}
}

Dieses kleine Stück Code definiert zwei Zustände Z1 und Z2; merke, dass pro Hauptschleifendurchlauf nur einmal das Case-Statement ausgeführt wird. Ich kann also in Z1 den currentScreenState ändern, ohne dass dies sofort Auswirkungen hat. Erst mit dem nächsten Durchlauf würde der Z2 angesprungen werden.

Die Funktion des längeren Drückens ist auch demonstriert: in Z1 muß man lange auf den Button drücken für eine Änderung, in Z2 reicht ein kurzer Druck aus. Aber Vorsicht: Der Zähler muß auf 51 gesetzt werden, wodurch er (siehe oben) nicht mehr inkrementiert wird. Würde dies nicht geschehen, so würde man in Z2 den Knopf gedrückt halten können, dann 49 Durchläufe in Z1 verbleiben und automatisch wieder in Z2 springen. In den meisten Fällen wird dieses Verhalten nicht gewünscht sein, sondern wie bei einer IR-Fernbedienung sollte die Aktion erst bei erneutem Druck ausgelöst werden. Das wird so bewerkstelligt.

5.6.6.4 Definition und Auswertung von Menüs

Auch bei Menüs gibt’s Besonderheiten zu beachten. Eine Funktion rendert dieses vollständig und animiert es, falls der alte selektierte Eintrag sich vom aktuellen unterscheidet.

Leider verliert der C-Compiler beim Übergeben von mehrdimensionalen Arrays die Dimension dieser und übergibt nur noch den Pointer auf die Startadresse. Nachdem ich in drei Dimensionen arbeite, war mir der Aufwand mit Structs und dem sizeOf()-Operator zu groß, ich übergebe daher Größe und Länge der Strings manuell. Die Funktion berechnet sich den Zeiger auf den auszugebenden String des Menüs selbst.

Auch geben die beiden letzten Parameter an, welche Zustände -sprich Einträge- des Menüs dargestellt werden sollen.

Wird nun currentMenuState mit <UP> und <DOWN> geändert, so kann die Funktion das Menü völlig selbstständig neu rendern.

Menürendering
void glcd_renderMenu(
	PGM_P menuHeader,
	PGM_P menuItems,
	unsigned char menuItemCount,
	unsigned char dimension,
	unsigned char menustate,
	unsigned char old_menustate) {
	unsigned char i;

	// Pfeil malen
	glcd_filledrectangle(3,16,7,17, COLOR_BLACK);
	glcd_linevertical(6,15,18);

	if (menustate==old_menustate) {
		// Keine Animation, da die Zustände gleich sind.
		// Wir müssen den Frame dank der Animation (im else-Teil weiter unten)
		// sowieso jedesmal neu zeichnen.
		glcd_drawMenuFrame(menuHeader);
		strcpy_P(vBuffer, menuItems + dimension * (menustate + currentLanguage * menuItemCount));
		glcd_puts(10, 10, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
		if (menustate<(menuItemCount-1)) {
			strcpy_P(vBuffer, menuItems + dimension*(menustate+1 + currentLanguage*menuItemCount));
			glcd_puts(10, 20, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
		}
	} else {
		// Zustände sind unterschiedlich, also animieren.
		if (menustate > old_menustate) {
			// Neuer Zustand größer als alter, also nach unten scrollen
			for (i=10;i>0;i-=2) {
				// Display leeren
				glcd_clear();
				// Menüpunkt eins vor dem aktuellen Menüpunkt zeichnen
				// Adresse wie folgt:
				// -- Basis des Strings
				// -- + Länge der Strings *(Index horizontal + Index vertikal)
				strcpy_P(vBuffer, menuItems + dimension*(menustate-1 + currentLanguage*menuItemCount));
				glcd_puts(10, i, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				// aktuelles Item zeichnen
				strcpy_P(vBuffer, menuItems + dimension*(menustate + currentLanguage*menuItemCount));
				glcd_puts(10, i+10, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				// ggf. nachfolgendes Item zeichnen
				if (menustate<(menuItemCount-1)) {
					strcpy_P(vBuffer, menuItems + dimension*(menustate+1 + currentLanguage*menuItemCount));
					glcd_puts(10, i+21, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				}
				// Weil jetzt die Schrift teilweise schon in den Header-Bereich ragt, den Rahmen und Überschrift noch einmal drüberzeichnen.
				glcd_drawMenuFrame(menuHeader);
				// Displayupdate
				glcd_repaint();
				// Und kurz warten
				_delay_ms(5);
			}
		} else {
			// Neuer Zustand kleiner als alter, also nach oben scrollen
			for (i=10;i<20;i+=2) {
				glcd_clear();
				strcpy_P(vBuffer, menuItems + dimension*(menustate + currentLanguage*menuItemCount));
				glcd_puts(10, i-10, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				strcpy_P(vBuffer, menuItems + dimension*(menustate+1 + currentLanguage*menuItemCount));
				glcd_puts(10, i, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				if (menustate<(menuItemCount-2)) {
					strcpy_P(vBuffer, menuItems + dimension*(menustate+2 + currentLanguage*menuItemCount));
					glcd_puts(10, i+11, vBuffer, Tahoma10Regular, 0, COLOR_BLACK);
				}
				glcd_drawMenuFrame(menuHeader);
				glcd_repaint();
				_delay_ms(5);
			}
		}
	}
}

Möchte ich die Auswahl eines Menüs auswerten und daraufhin Bildschirm wechseln, so geschieht dies durch ein weiteres CASE-Statement innerhalb eines Bildschirms:

Menüauswertung
// [...]
case MYMENUSCREEN:
	glcd_renderMenue(/* ... */, currentMenuState, oldMenuState);
	oldMenuState = currentMenuState;
	if (upBtn==1 && currentMenuState>0) { currentMenuState--; }
	if (dnBtn==1 && currentMenuState<7) { currentMenuState++; } // Menü mit 8 Einträgen [0-7]
	if (okBtn==1) {
		switch(currentMenuState) {
			case 0: currentScreenState = CONFIGMENU; break;
			case 1: currentScreenState = HANDICAPMENU; break;
			// ...
			case 7: currentScreenState = MAINSCREEN; break;
		}
		okBtn = 51;
	}
break;
// [...]

So wird zunächst das Menü gerendert und wenn erforderlich animiert und dann auf Knopfdrücke reagiert: Menüselektion wechseln und bei OK den neuen Screen anspringen im nächsten Durchlauf der Hauptschleife. Damit ist die Hauptschleifen-Statemachine schon erklärt. Für die einzelnen Zustände möge man wiederum den dokumentierten Quelltext zu Rate ziehen.

5.6.6.5 Aktualisieren des Displays

Bleibt nur noch, die gezeichneten Inhalte tatsächlich vom VRAM auf das LCD zu schreiben, das geschieht am Ende der Hauptschleife.

LCD-Update
while (1) {
	// Buttons hier
	// Seriell hier
	// glcd_clear() hier
	// State-Machine hier
	glcd_repaint();
}

8 Kommentare zu “Pimp-My-Kicker a.k.a. Wham-O-Meter”

1.   Kommentar von doc
Erstellt am 26. August 2010 um 17:17 Uhr.

Sieht ziemlich cool aus. Kann’s kaum erwarten, mal daran zu spielen!

2.   Kommentar von Waldemar
Erstellt am 17. April 2013 um 22:15 Uhr.

Hi , ich habe mal dein Wham-O-Meter nachgebaut (Einseitig) nur für die Anzeige der Geschwindigkeiten! COOL das Teil. DANKE dir dafür 😉 Ich bin auch ein Kickerfanatiker und E-Techniker u. ich muss sagen das Teil funktioniert recht gut, obwohl Bälle die weiter unten an den Photoempfängern vorbeirauschen langsamer angezeigt werden als weiter oben (Streuung der IR-LED ist Schuld). Hab noch paar Komentare:

bzgl. deiner Ball-Durchmesser-Kalibrierungsfunktion:
-> die läuft zwar, macht aber nix !!!

-> hab die Kalibrierung per hand vorgenommmen. Einfach den
Balldurchmesser eingeben, funktioniert aufgrund der IR-Streuung
nicht korrekt! Ich ließ den Ball öfters durch die Lichtschranke von
ner Höhe h fallen und hab die Geschwindigkeit nach v=sqr(2gh) bestimmt
und verglichen mit der Anzeige. Der wahre Durchmesser ist bei meinem
Ball 35mm u. damit die Aneige stimmt muß ich bei mir D = 27mm eingeben.

bzgl. Die Top-Ten-Liste:
-> Die Top-Ten-Liste sollte vorher schon mit Werten gefüllt werden,
( vielleicht ab 25 Km/h ), da das ständige Eintippen der Rekorde
am Anfang doch schon ziemlich nervt 😉

Nun denn weiter so ! Gruss aus Berlin

3.   Kommentar von McSeven
Erstellt am 17. April 2013 um 22:31 Uhr.

Grüße, freut mich ja sehr, dann wärst Du schon der dritte, der’s erforlgreich nachgebaut hat. Wenn du hast, schick gerne Fotos, bau ich dann hier auf die Webseite.

Re 1) Ok, das kann sein. Ist wohl eher eine Spielerei, wobei es bei mir funktionierte…

Re 2) Ah richtig, es kommt wirklich auf die IR-Strahlen drauf an. Wir hatten dann noch Strohhalme drübergestülpt, um die Streuung zu verbessern. Du könntest auch mal schauen, ob die LEDs richtig auf die Transistoren ausgerichtet sind…

Re 3) Gute Idee, dazu wäre einfach im EEPROM-Initial-Wert (bei dem Array) statt 10x “ “ ein passender Wert einzutragen, z.B. 10x „CPU“ mit eben 25,00km/h. Das kannst sogar selbst machen und dann flashen.

„Nur einseitig???“ Spaß machts doch erst mit zwei LCDs =) Cheers

4.   Kommentar von Waldemar
Erstellt am 14. August 2014 um 19:19 Uhr.

Hallo Christoph,
ich habe mal wieder Zeit gefunden und habe mein Kicker um eine zweite Anzeige erweitert. Mein Bruder will jetzt auch einen Wham-O-Meter 😉 Deshalb hab ich hier wieder reingeschaut und mich gewundert, dass du so schnell geantwortet hast auf mein Kommentar von letztem mal 😉 Da bin ich etwas langsamer ;(
Das mit den Initialwerten im EEPROM hatte ich natürlich auch so gemacht. Auch deine Idee mit den Strohhalmen hatte ich in etwas massiverer Form siehe Bilder) LG Waldemar

5.   Kommentar von Ole
Erstellt am 08. Januar 2015 um 09:48 Uhr.

Hey, größten Respektfür das, was ihr geleistet habt. Ich bin total neidisch darauf und würde es gerne nachbauen. Leider bin ich ein absoluter Anänger und stehe mit der Fertigung der Platinen vor einem Problem. Könnt ihr mir da vielleicht eine Anleitung zur Fertigung geben, die für absolute dummies ist?
Liebe Grüße!

6.   Kommentar von McSeven
Erstellt am 08. Januar 2015 um 09:56 Uhr.

Grüße, Danke erst einmal. Also, dann würde ich Dir den PCB-Pool empfehlen, der macht super Platinen. Sich das selbst beizubringen für so ein großes Projekt ist vielleicht etwas viel. Ansonsten existieren im Internet dutzende Anleitungen, eine Suchmaschine Deiner Wahl hilft Dir unter dem Stichwort „Platinen ätzen“ weiter. Cheers, Christoph

7.   Kommentar von Ole
Erstellt am 08. Januar 2015 um 15:15 Uhr.

Ui, habe mir gerade dazu Videos angeguckt und sehe, dass das sehr schwierig werden wird… Eine weitere Sache: ich möchte dieses Projekt mit einem Arduino verwirklichen, der hat aber keine 24 Ports. wie habt ihr das Portproblem gelöst ?
LG

8.   Kommentar von McSeven
Erstellt am 08. Januar 2015 um 22:55 Uhr.

Grüße, hm, also wie ich soll ich das schonend verpacken… Eventuell ist so eine komplexe Sache für den Einstieg in die Mikrocontroller-Welt etwas viel. Mit dem Arduino-Ökosystem kenne ich mich nicht so gut aus, kann Dir also nicht garantieren, daß die Firmware, die aus den Sketches erstellt wird, auch wirklich schnell genug für das genaue Zeitmessen ist. Zu Deiner Frage: Der Mega644 hat die entsprechende Anzahl an GPIO-Pins mit Pin-Change-Interrupts, deswegen hatten wir damals kein Problem 🙂

Einen Kommentar hinterlassen