Teil 3: Und jetzt in Farbe

Grafiken auf OLED-Displays farbig darstellen

Tam Hanna

© agsandrew/Shutterstock.com

Das im letzten Heft verwendete monochrome OLED-Display war insofern haarig, als es keine farbigen Grafiken darstellen konnte. In diesem Heft wollen wir das Problem angehen und das Zigarrenbudget des Autors zum Kauf eines Farbdisplays einsetzen.

Der chinesische Anbieter AliExpress ist eine niemals enden wollende Quelle für Maker, Elektroniker und Maschinisten – so gibt es kaum etwas, dass man mit etwas Sucharbeit nicht bekommt.

Artikelserie

Die etablierten Displays auf Basis des SSD1360 haben seit einiger Zeit moderne Nachfolger bekommen, die auf dem ebenfalls von Solomon Systech entwickelten SSD1351 basieren. Achten Sie darauf, ein passendes Display zu erbeuten – Abbildung 1 zeigt eines neben einem nicht kompatiblen Kollegen.

Abb 1: Nicht jedes auf AliExpress erhältliche Farbdisplay ist kompatibel (Quelle: Instagram/tam.hanna)

Abb 1: Nicht jedes auf AliExpress erhältliche Farbdisplay ist kompatibel (Quelle: Instagram/tam.hanna)

Nach der Bestellung …

Die Verwendung eines vollgrafischen Bildschirms ermöglicht uns die Anzeige von beliebigen Elementen. Wir wollen das in den folgenden Schritten insofern ausnutzen, als MPAndroidCharts zur Anzeige eines Temperaturverlaufdiagramms eingespannt wird.

Das im letzten Heft verwendete SPI-Bus-Anzeigeprogramm lieferte mehrere Busse zurück – auf dem Raspberry Pi 3 haben wir 2 SPI-Busse zur Verfügung. Wir wollen in den folgenden Schritten das neue und das alte Display nebeneinander betreiben, was zur in Abbildung 2 gezeigten Schaltung führt.

Abb. 2: Das CS-Signal entscheidet, welches Display aktiv ist

Abb. 2: Das CS-Signal entscheidet, welches Display aktiv ist

Angemerkt sei, dass die Stromversorgung des Raspberry Pi durch die beiden Displays an die Belastungsgrenze gebracht wird. Während das 5V-Signal direkt vom Netzteil stammt, kommen die 3V3 aus einem nicht sonderlich leistungsstarken Schaltregler. Das farbige OLED kann – je nach angezeigtem Bildschirminhalt – bis zu 150 mA fressen.

Wie im letzten Heft bringen wir den Treiber in einer „dedizierten“ Klasse unter, deren Header folgendermaßen aussieht:

public class SSD1351Driver {
  SpiDevice myDevice;
  Gpio myPinReset, myPinDataCommand;
  byte[] myFramebuffer=new byte[128*128*2];

Da sich die beiden Displays die Reset- und DC-Pins teilen, können wir sie im Konstruktor nicht neu vom Peripheral Manager anfordern. Stattdessen liefern wir die fertigen Objektinstanzen an:

public SSD1351Driver(Gpio _reset, Gpio _dc){
  myPinReset=_reset;
  myPinDataCommand = _dc;
  try{
  PeripheralManager manager = PeripheralManager.getInstance();
  myDevice = manager.openSpiDevice("SPI0.1");

Die Konfiguration des SPI-Busses erfolgt nach dem bekannten Schema. Der einzige Unterschied ist, dass wir nun den String SPI0.1 als Peripheral ID übergeben.

… ist vor der Parametrierung

Der SSD1351 unterscheidet sich von seinem primitiveren Kollegen dadurch, dass die Übertragung von Informationen nun komplizierter erfolgt. Nach dem Senden eines Kommandos erwartet der Controller mitunter weitere Informationen, die über die Datenebene anzuliefern sind.

Eine wesentliche Vereinfachung erreichen wir dadurch, dass wir die Übertragungsfunktionen anpassen (Listing 1).

void SendData(byte _what){
  try {
    byte[] realWhat=new byte[1];
    realWhat[0]=_what;
    myPinDataCommand.setValue(true);
    myDevice.write(realWhat, realWhat.length);
  }

Was auf den ersten Blick wie eine einfache Ausgabe klingt, erweist sich in der Praxis als knifflig: Write erwartet die Übergabe eines Arrays und kann mit Einzelbytes nichts anfangen. Zur Lösung des Problems errichtet unsere Methode im ersten Schritt ein neues Feld, das nach der Vorbereitung an das API wandert.

Ein alter Kalauer besagt, dass die Komplexität der Einrichtungsroutine proportional zur Komplexität des Displaycontrollers anwächst. Da das seitenweise Abdrucken von Registeradressen an dieser Stelle nicht produktiv ist, zeigen wir Ihnen nur den Beginn von initDisplay (Listing 2).

void initDisplay() {
  SendCommand(SSD1351_CMD_COMMANDLOCK);  
  SendData((byte)0x12);
  SendCommand(SSD1351_CMD_COMMANDLOCK);  
  SendData((byte)0xB1);
  SendCommand(SSD1351_CMD_DISPLAYOFF);
  SendCommand(SSD1351_CMD_CLOCKDIV);
  SendCommand(SSD_CLOCKDIV);

Im Code zu diesem Heft finden Sie naturgemäß eine funktionierende Variante. Wer mehr über die einzelnen Register erfahren möchte, kann das bereitstehende Datenblatt analysieren oder eine der Beispieltreiberimplementierungen für Arduino und Co. ansehen. Solomon Systech ist insofern unkooperativ, als man Datenblätter nur ungern herausrückt – erfreulicherweise findet sich einiges am Graumarkt.

An dieser Stelle sind wir für einen ersten Test bereit. Wie im letzten Fall gilt auch hier, dass ein leeres organisches Display schwarz und somit tot erscheint, weshalb wir im ersten Schritt Informationen anliefern müssen (Listing 3).

void testDisplay(){
  SendCommand(SSD1351_CMD_SETCOLUMN);
  SendData((byte)0x00);
  SendData((byte)0x7F);
  SendCommand(SSD1351_CMD_SETROW);
  SendData((byte)0x00);
  SendData((byte)0x7F);
  SendCommand(SSD1351_CMD_WRITERAM);
  for(int i=0;i<128*128;i++){
    SendData((byte) 0xFF);
    SendData((byte) 0xFF);
  }
}

Aus technischer Sicht unterscheidet sich diese Methode durch zwei Aspekte vom Vorgänger. Erstens übertrafen wir eine Gruppe von Befehlen, um den Controller zum Annehmen von Framebuffer-Daten zu befähigen. Zweitens übertragen wir pro Pixel 16 Bit, was das zweimalige Aufrufen von SendData mit je einem 8 Bit langen Wert erforderlich macht.

An dieser Stelle können wir den neuen Treiber in onCreate einbinden. Der dazu notwendige Code ist nicht sonderlich kompliziert (Listing 4).

@Override
protected void onCreate(Bundle savedInstanceState) {
  . . .
  mySSD1306 = new SSD1306Driver();
  mySSD1306.initDisplay();
  mySSD1351 = new SSD1351Driver(mySSD1306.myPinReset, mySSD1306.myPinDataCommand);
  mySSD1351.initDisplay();
  mySSD1351.testDisplay();
}

Von der algorithmischen Komplexität

Bilddaten gelten seit jeher als höchst haarig – selbst unser mit 128 × 128 Pixel sehr kleines Display bringt 16 384 Punkte mit. Das zeigt sich unter anderem, wenn Sie unser Programm im vorliegenden Zustand ausführen. Die Einfärbung des Displays erfolgt so langsam, dass Fotos wie Abbildung 3 ohne großen Aufwand entstehen.

Abb. 3: Die Ansteuerung sorgt für eine sehr langsame Aktualisierung des Bildschirms

Abb. 3: Die Ansteuerung sorgt für eine sehr langsame Aktualisierung des Bildschirms

Ein schneller Weg bestünde darin, dass Display zeilenweise zu beschreiben. Hierzu benötigen Sie den Code aus Listing 5.

for(int i=0;i<256;i++){
  myFramebuffer[i] = (byte)0xFF;
}
try{
  for(int i=0;i<128;i++){
    myPinDataCommand.setValue(true);
    myDevice.write(myFramebuffer, 256);
  }
}
catch (Exception e){
}

Aus didaktischen Gründen wollen wir momentan keinen kompletten Framebuffer übertragen, sondern beschränken uns auf das Senden der immer gleichen 256 Bytes. Die for-Schleife hat die Aufgabe, die zu übertragende Informationen in Richtung der SPI Engine zu jagen.

Was auf den ersten Blick wie eine pedantisch sinnlose Handlung aussieht, ist in der Praxis von höchster Relevanz. Zur Demonstration der Probleme böte es sich an, nach dem folgenden Schema einen Versuch zur Übertragung des gesamten Buffers zu übernehmen:

myPinDataCommand.setValue(true);
myDevice.write(myFramebuffer, myFramebuffer.length);

Wenn Sie das vorliegende Programm ausführen, so sehen Sie einen chaotischen Screen mit beliebigen Farbpunkten – es ist offensichtlich, dass die Übertragung der Informationen zwischen Raspberry Pi und Displaycontroller nicht zustande kommt.

Die Ursache für dieses auf den ersten Blick seltsame Verhalten zeigt sich beim Öffnen des LogCat-Fensters. Bei aufmerksamer Suche finden Sie dort den Fehler „E/peripheralman: [0117/170434:ERROR:spi_driver_spidev.cc(105)] SPI Transfer IOCTL Failed: Message too long.“

Das in Android Things enthaltene SPI API ist nicht in der Lage, große Datenblöcke für die Hardware aufzubrechen – wer große Mengen an Informationen per SPI senden möchte, muss sie händisch in kleinere Elemente aufteilen. An dieser Stelle ist unser Programm vorerst fertig – führen Sie es aus, um sich an einem schnell aktualisierten weißen Bildschirm zu erfreuen.

Intermezzo: Diagrammbibliothek einbinden

Da dieser Artikel zu Android Things nicht in eine Besprechung von Gradle ausarten soll, wollen wir die Einbindung schmerzlos erledigen. Öffnen Sie im ersten Schritt die zum Projekt gehörende build.gradle und adaptieren Sie sie wie in Listing 6 dargestellt.

allprojects {
  repositories {
    google()
    jcenter()
    maven { url "https://jitpack.io" }
  }
}

Achten Sie dabei darauf, nicht versehentlich statt allprojects die Rubrik XXX zu erwischen. Im nächsten Schritt wechseln wir in die zum Modul App gehörende build.gradle-Datei, wo wir MPAndroidCharts wie gewohnt einbinden:

dependencies {
  . . .
  compileOnly 'com.google.android.things:androidthings:+'
  implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0-alpha'
}

Danach ist bei bestehender Internetverbindung eine Aktualisierung des Projekts erforderlich, um Android Studio Gelegenheit zum Herunterladen der fehlenden Komponenten zu geben. Im nächsten Schritt benötigen wir etwas Plumbing, um die Diagrammanzeige zu ermöglichen.

Der Android-GUI-Stack erweist sich an dieser Stelle als Gegner, da er nur leidlich für die Arbeit mit sehr kleinen Displays vorgesehen ist. Es ist von eminenter Bedeutung, das Widget gleich in der Deklaration in Bezug auf die Größe zu beschränken (Listing 7).

<android.support.constraint.ConstraintLayout . . .
  <com.github.mikephil.charting.charts.LineChart
    android:id="@+id/chart1"
    android:layout_width="128px"
    android:layout_height="128px"/>
</android.support.constraint.ConstraintLayout>

An dieser Stelle ein kleiner Hinweis: Während der Entwicklung der Visualisierung ist es empfehlenswert, das Steuerelement größer zu machen und einen externen Monitor anzuschließen. In diesem Fall können Sie die Auswirkung der verschiedenen Parameter bequem studieren, ohne permanent auf das winzige OLED blicken zu müssen.

Im nächsten Schritt müssen wir MainActivity um einige globale Member erweitern. Zwei davon stammen aus der MPAndroidCharts-Bibliothek, während das dritte Feld für das Vorhalten der eingesammelten Sensordaten verantwortlich ist:

LineDataSet s1;
LineData data;
Vector<Float> myFloats = new Vector<Float>();

Die Konfiguration von Diagrammen auf Basis von MPAndroidCharts ist am Smartphone vergleichsweise einfach. Da wir es hier mit einem sehr kleinen Display zu tun bekommen, ist die Initialisierung etwas länger. Wer sich mit der Diagrammbibliothek auskennt, stellt fest, dass der Gutteil des Codes auf das Deaktivieren von diversen Funktionen zurückzuführen ist, die wertvollen Bildschirmplatz in Anspruch nehmen (Listing 8).

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  LineChart mChart = (LineChart) findViewById(R.id.chart1);
  mChart.getDescription().setEnabled(false);
  ArrayList<Entry> values = new ArrayList<Entry>();
  for (int i = 0; i < 20; i++) {
    float val = (float) (Math.random() * 25) + 3;
    values.add(new Entry(i, val));
  }
  s1=new LineDataSet(values,"");
  s1.setDrawCircles(false);
  s1.setDrawValues(false);
  s1.setColor(Color.BLACK);
  Legend l = mChart.getLegend();
  l.setEnabled(false);
  ArrayList<ILineDataSet> dataSets = new ArrayList<ILineDataSet>();
  dataSets.add(s1);
  data = new LineData(dataSets);
  mChart.setData(data);
  mChart.setDrawGridBackground(false);

Das sofortige Einfügen von Dummydaten in das Diagramm hilft bei der Entwicklung – das Sammeln von 20 Messwerten würde 20 Sekunden in Anspruch nehmen, was die Analyse der Auswirkung von Änderungen verlangsamt.

MPAndroidChart finanziert sich unter anderem dadurch, dass das Entwicklerteam eine fortgeschrittene Version der Diagrammbibliothek mit diversen Optimierungen für dynamisch aktualisierte Informationen anbietet. Die Situation ist zwar beschrieben, doch wir wollen hier trotzdem mit der kostenlosen Variante arbeiten.

Diese verlangt die komplette Aktualisierung der Inhalte des DataSets bei jedem Durchlauf. Aus diesem Grund erzeugen wir das weiter oben abgedruckte Array, das im Rahmen jedes Durchlaufs einen neuen Wert aufnimmt. Der Autor setzt hier aus Gründen der Bequemlichkeit auf eine Selektion, um das Löschen von Elementen nur bei akutem Bedarf durchführen zu müssen (Listing 9).

myFloats.add(c);
if(myFloats.size()>=20)myFloats.removeElementAt(0);
s1.clear();
for(int i=0;i<myFloats.size();i++) {
  s1.addEntry(new Entry(i, myFloats.elementAt(i)));
}

Im Runnable müssen wir dafür sorgen, dass die diversen Datenstrukturen des Diagramms die neuen Informationen entgegennehmen. Das mehrfache Aufrufen der Methode notify* ist keine Pedanterie des Autors – das Entwicklerteam versucht Ihnen aus den weiter oben genannten Gründen, das Leben so schwer wie irgend möglich zu machen (Listing 10).

LineChart chart = (LineChart) findViewById(R.id.chart1);
data.notifyDataChanged();
chart.notifyDataSetChanged(); // let the chart know it's data changed
chart.invalidate(); // refresh
s1.setAxisDependency(YAxis.AxisDependency.LEFT);
YAxis yAxis = chart.getAxisLeft();
yAxis.setAxisMaximum(30);
yAxis.setAxisMinimum(10);
yAxis = chart.getAxisRight();
yAxis.setEnabled(false);
myView.setText(c.toString() +  " °C");
mySSD1306.renderString(c.toString() +  " °C");

Wenn Sie das Programm an dieser Stelle mit angeschlossenem Bildschirm ausführen, sehen Sie ein sich permanent aktualisierendes Diagramm. Unsere nächste Aufgabe ist das Extrahieren der Bilddaten, die idealerweise in Form einer Bitmap vorliegen sollten. Der beste Weg dazu sieht aus wie in Listing 11 dargestellt.

LineChart mChart = (LineChart) findViewById(R.id.chart1);
Bitmap returnedBitmap = Bitmap.createBitmap(128, 128,Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(returnedBitmap);
Drawable bgDrawable =mChart.getBackground();
if (bgDrawable!=null)
  bgDrawable.draw(canvas);
else
  canvas.drawColor(Color.WHITE);
mChart.draw(canvas);
mySSD1351.drawThis(returnedBitmap);
handler.postDelayed(runnable, 1000);

Und nun aufs Display

Nach der Abarbeitung der Methode draw enthält returnedBitmap das Resultat der Zeichenoperation – in unserem Fall ist dies ein vollfarbiges Abbild des Diagramms. Unsere nächste Aufgabe ist, dieses in ein für den SSD1351 verständliches Format zu bringen. Im Datenblatt des Controllers findet sich das in Abbildung 4 gezeigte Diagramm, das die verschiedenen Arten der Speicherrepräsentation beschreibt.

Abb. 4: Solomon Systech zeigt sich flexibel

Abb. 4: Solomon Systech zeigt sich flexibel

Die für uns relevante Zeile lautet 16bit 65k, weshalb wir in der Treiberklasse eine neue Zeichenmethode anlegen und diese im ersten Schritt zum Ausgeben dieser Daten befähigen müssen. Wer unter C programmieren würde, könnte hier auf ein Bildfeld zurückgreifen – mit etwas Handarbeit bekommt man normalerweise eine einsatzbereite Variable, deren Ergebnisse man nur noch auf den Bildschirm schreiben muss.

Bei der Arbeit unter Java ist die Situation etwas schwieriger, weshalb wir auf klassische Bitarithmetik setzen. Im ersten Schritt müssen wir die Werte auf eine akzeptable Länge reduzieren. Abbildung 4 zeigt uns, dass für die rote und die blaue Komponente fünf Bits, für die grüne Komponente aber sechs Bits vorgesehen sind. Die Disparität liegt darin begründet, dass das menschliche Auge grün besser auflösen kann – der Aufbau von Samsungs organischen Displays ist ähnlich motiviert.

Amtshandlung Nummer eins besteht darin, die eingehenden Pixel eines nach dem anderen zu skalieren. Dazu verwenden wir getPixel, um die Farbkomponenten danach per Bitarithmetik zu isolieren (Listing 12).

public void drawThis(Bitmap returnedBitmap) {
  int r, g, b;
  for (int x=0;x<128;x++)
  {
    for (int y=0;y<128;y++)
    {
      int px = returnedBitmap.getPixel(x, y);
      r = (px & 0x00ff0000) >> 16;
      g = (px & 0x0000ff00) >> 8;
      b = px & 0x000000ff;

Framebuffer-Swizzeling-Code ist eine jener Aufgaben, bei der sich auch sehr diensterfahrene Entwickler schwertun. Aus diesem Grund ist es empfehlenswert, nach der Extraktion der einzelnen Farbkomponenten einen weiteren Codeblock einzufügen, über den sie die einzelnen Farbkomponenten direkt ansprechen können. Auf diese Art und Weise können Sie die Grafikroutine dazu zwingen, einen roten, grünen oder blauen Bildschirm anzuzeigen, und so die Zuordnung zwischen Feldern und Wörtern kontrollieren:

//Test hier
/*r=254;
g=0;
b=0;*/

Unter diesem — bei Bedarf auskommentierbaren — Code findet sich dann die Skalierung. Der Autor setzt hier statt auf Divisionen auf Schiebeoperationen. Das ist ein netter Weg, um dem Rechner die oft aufwandsintensive Hardwaredivision zu ersparen:

r = (byte)(r >> 3);
g = (byte)(g >> 2);
b = (byte)(b >> 3);

Zu guter Letzt müssen wir die angelieferten Farbkomponenten in ein 32 Bit langes Integer zusammensetzen, das wir danach in den als Byte-Array vorliegenden Framebuffer kopieren (Listing 13):

 int out = b + (g<< 5) + (r<<11);
      int lesser =out >> 8;
      int greater =out & 0xFF;
      myFramebuffer [(x*128+y)*2+1]= (byte) greater;
      myFramebuffer [(x*128+y)*2]= (byte) lesser;
    }
  }

Damit liegen die Bildinformationen in einem zur Übertragung an das Display geeigneten Format vor. Unsere nächste Aufgabe ist das Sicherstellen des „Herausschreibens“ der Informationen. Wie in testDisplay beginnen wir auch hier damit, einige Befehle in Richtung des Controllers zu senden. Diese stellen sicher, dass die angelieferten Informationen an die richtige Stelle im Framebuffer wandern (Listing 14).

SendCommand(SSD1351_CMD_SETCOLUMN);
SendData((byte)0x00);
SendData((byte)0x7F);
SendCommand(SSD1351_CMD_SETROW);
SendData((byte)0x00);
SendData((byte)0x7F);
SendCommand(SSD1351_CMD_WRITERAM);

Als nächste Aufgabe müssen wir die eigentlichen Daten übertragen. Java macht uns das Leben an dieser Stelle insofern schwer, als es keinen direkten Weg zur Aufteilung des Framebuffers gibt. Google hat ebenfalls geschlampt, weil die Methode write() auch kein Offset entgegennimmt – der einzige Lösungsweg besteht darin, das Feld zur Laufzeit in einzelne Teile aufzusplitten (Listing 15).

try{
  byte[] whichBuffer;
  for(int i=0;i<16;i++){
    myPinDataCommand.setValue(true);
    whichBuffer = Arrays.copyOfRange(myFramebuffer, i*2048, i*2048+2048);
    myDevice.write(whichBuffer, 2048);
  }
}
catch (Exception e){
}

Zur Beschleunigung böte sich beispielsweise die Verwendung von nativem Code an, um den Garbage Collector zu entlasten. An dieser Stelle sei aber vor Überoptimierung gewarnt – auf dem Prozessrechner des Autors funktionierte die Aktualisierung problemlos.

Damit sind wir auch schon für einen ersten Testlauf unseres Programms bereit. Jagen Sie es auf einen Raspberry Pi, und befehlen Sie die Ausführung – die Ausgabe präsentiert sich wie in Abbildung 5 gezeigt.

Abb. 5: Unser Diagramm steht Kopf

Abb. 5: Unser Diagramm steht Kopf

Zur Verbesserung dieser Situation bietet sich das Anpassen des Laufverhaltens der Schleife an. Ein gangbarer Weg würde folgendermaßen aussehen:

public void drawThis(Bitmap returnedBitmap) {
  int r, g, b;
  for (int y=0;y<128;y++)
  {
    for (int x=0;x<128;x++)
    {
      int px = returnedBitmap.getPixel( y, x);

An dieser Stelle ist unser Programm zur Ausführung bereit. Abbildung 6 zeigt das Diagramm, das auf dem Bildschirm erscheint. Zur Verfeinerung könnten Sie die Farbwerte anpassen – eine Aufgabe, die wir Ihrer Kreativität überlassen.

Abb. 6: Das Temperaturdiagramm ist gefechtsbereit

Abb. 6: Das Temperaturdiagramm ist gefechtsbereit

Fazit

Auch wenn der Aufwand mit dem Swizzeling auf den ersten Blick erschlagend wirkt – in der Praxis spart die Verwendung von MPAndroidCharts und Android Things wertvolle Zeit. Möchten Sie beispielsweise statt einem Linien- ein Tortendiagramm zeichnen, so sind nur minimale Änderungen am Code erforderlich.

Damit haben wir die Besprechung aller Hardwareschnittstellen abgeschlossen. Im nächsten und letzten Teil dieser Serie wenden wir uns den in Android enthaltenen Möglichkeiten zur Programmverwaltung zu – Google unterstützt die P.-T.-Entwickler beispielsweise bei der Aktualisierung von schon im Feld befindlicher Hardware. Bis dahin wünschen wir Ihnen viel Vergnügen!

Geschrieben von
Tam Hanna
Tam Hanna
Tam Hanna befasst sich seit der Zeit des Palm IIIc mit der Programmierung und Anwendung von Handcomputern. Er entwickelt Programme für diverse Plattformen, betreibt Onlinenewsdienste zum Thema und steht unter tamhan@tamoggemon.com für Fragen, Trainings und Vorträge gern zur Verfügung.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: