Blauer Himmel mit C#
Kommentare

Wer kennt sie nicht, die kleine Wetterstation, die Temperatur, Feuchtigkeit und Luftdruck anzeigt. Vielleicht ist die Bezeichnung Wetterstation etwas hochtrabend, dennoch können anhand dieser drei Sensoren einige Rückschlüsse gezogen werden. Denn durch das Erfassen und Speichern der Messungen können Diagramme erstellt und somit auch Prognosen abgeleitet werden.

Mit den Arduino Boards konnten viele Entwickler den Einstieg in die Programmierung eigener Funktionen finden ohne eine Schaltung löten zu müssen. Das konnte beispielsweise mit der Festlegung eines Board-Schemas erzielt werden, auf das sich so genannte Shields aufsetzen lassen. Im Hardwarebereich sind hier allerdings keine Neuerungen zu verzeichnen; den verwendeten 8-Bit-Mikrocontroller aus der ATMega-Serie gibt es schon seit einiger Zeit. Der Netduino hingegen hat einen modernen ARM-7- oder ARM-9-Mikrocontroller mit 32 Bit. Dieser zeichnet sich unter anderem durch eine höhere Anzahl an Schnittstellen aus. Was ihn allerdings wirklich interessant macht, ist die Verwendung von C# und des .NET Micro Frameworks. Und da der Netduino formgleich mit dem Arduino ist, können die meisten Arduino Shields ebenfalls für ersteren verwendet werden. Schwierigkeiten können jedoch auftreten, wenn Komponenten verwendet werden, die 5 V als Referenzspannung benötigen, da der Netduino selbst grundsätzlich mit 3,3 V an den Pins arbeitet. Die Module mit den aufgelöteten Sensoren haben daher oft einen Pegelwandler, oder der Sensor selbst unterstützt verschiedene Spannungshöhen. Doch genug möglicher Hürden, schließlich soll hier aufgezeigt werden, dass ein .NET-Entwickler mit Heimvorteil ebenfalls die kleinen Mikrocontroller programmieren kann.

Für die Entwicklung mit C# und dem .NET Micro Framework reicht Visual Studio 2012 oder 2010 sowie das .NET Micro Framework SDK 4.2 [1] Die Installation einer zusätzlichen Entwicklungsumgebung ist nicht notwendig. Wie bereits erwähnt, wird als Hardware der Netduino eingesetzt. Die in der folgenden Auflistung genannten Sensoren sind anhand ihrer Verbindung I²C gewählt:

  • Netduino 1 oder 2 (oder auch die Plus-Varianten) [2]
  • HTU21D (Temperatur- und Luftfeuchtigkeitssensor) [3]
  • BMP085 (Luftdrucksensor) [4]
  • 2 x 2,2-kOhm-Widerstände
  • Breadboard (Steckboard)
  • 8 Stückbrücken

Für die Schaltung sind keine tiefgehenden Kenntnisse in der Elektrotechnik erforderlich. Das Netduino-Board kann ab ca. 30 Euro bezogen werden. Die Sensoren liegen zwischen 8 und 15 Euro. Wenn Sie sich mit dem Basteln Zeit lassen können, bestellen Sie die Sensoren am besten über eBay bei einem chinesischen Händler für einen deutlich günstigeren Preis. Die Versanddauer liegt bei drei bis acht Wochen, dafür ist der Versand kostenfrei.

Basis für die I²C

Zunächst wird in Listing 1 für die zwei späteren Sensorklassen eine gemeinsame Basis mit dem Namen BaseDevice erstellt, von der später geerbt wird. In ihr stehen die grundsätzlichen Methoden zur Verfügung. Allgemein wird I2CDevice.Configuration benötigt, in der die eigene Sensorklasse ihre Adresse und Takteinstellungen hinterlegt. Die Init-Methode soll den einzelnen Sensor initialisieren können, der später für den Luftdrucksensor erforderlich sein wird. Mit SetValues wird mit der übergebenen Instanz der I2CDevice-Klasse die Konfiguration zugewiesen. Im Anschluss wird die Methode SetMessureResults aufgerufen, in der die Abfragen zum Sensor abgearbeitet werden. Die Methoden Write und Read ermöglichen die grundsätzliche Kommunikation anhand von übergebenen Byte Arrays.

 
public class BaseDevice
{
  internal I2CDevice.Configuration _Configuration;
  public BaseDevice(byte address, int clock)
  {
    this._Configuration = new I2CDevice.Configuration(address, clock);
  }
  
  public virtual void Init(ref I2CDevice i2c)
  {
    i2c.Config = this._Configuration;
  }
  
  public void SetValues(ref I2CDevice i2c, ref SensorData data)
  {
    i2c.Config = this._Configuration; 
    this.SetMessureResults(ref i2c, ref data);
  }
  
  public virtual void SetMessureResults(ref I2CDevice i2c, ref SensorData data)
  {
  }
  
  // Sendet den Inhalt des Byte Arrays
  internal int Write(ref I2CDevice i2c, byte[] buffer)
  {
    I2CDevice.I2CTransaction[] trans = new I2CDevice.I2CTransaction[]
    {
      I2CDevice.CreateWriteTransaction(buffer)
    };
    return i2c.Execute(trans, 1000);
  }

  // Liest den zu empfangenen Byte Array
  internal int Read(ref I2CDevice i2c, byte[] buffer)
  {
    I2CDevice.I2CTransaction[] trans = new I2CDevice.I2CTransaction[]
    {
      I2CDevice.CreateReadTransaction(buffer)
    };
    return i2c.Execute(trans, 1000);
  }
}

Messergebnisse sammeln

Zum Erfassen der Messungen wird noch ein Datenobjekt benötigt, das den Namen SensorData erhält. Die Property IsMessureValid gibt an, ob die letzten Messungen in Ordnung waren. Die drei weiteren Angaben dürften selbsterklärend sein. Für die spätere Ausgabe im Output von Visual Studio kann noch die ToString-Methode überschrieben werden. In ihr sollen die Ergebnisse zusammengefasst und in lesbarer Form ausgegeben werden (Listing 2).

 public class SensorData
{
  // Sagt aus, ob der Inhalt fehlerfrei gelesen wurde 
  public bool IsMessureValid { get; set; }
  
  // Messung der Temperatur aus dem HTU21D
  public float Temperature { get; set; }
  
  // Messung der Luftfeuchtigkeit aus dem HTU21D
  public float Humidity { get; set; }
  
  // Messung des Luftdrucks
  public float Pressure { get; set; }
  
  // Gibt den gesamten Inhalt als fertigen String zurück
  public override string ToString()
  {
    StringBuilder sb = new StringBuilder( "FEHLER!");
    
    if (this.IsMessureValid)
    {
      sb.Clear();
      sb.AppendLine("Temperatur:   " + this.Temperature.ToString() + " °C");
      sb.AppendLine("Feuchtigkeit: " + this.Humidity.ToString() + " %");
      sb.AppendLine("Pressure:     " + this.Pressure.ToString() + " Pa");
    }
    return sb.ToString();
  }
}

Messungen mit dem ersten Sensormodul

Der Feuchtigkeitssensor HTU21D misst die Feuchtigkeit und die Temperatur. Als Erstes erbt die Klasse vom BaseDevice und erhält somit die Grundfunktionen. Im Konstruktor selbst wird eine neue Konfiguration mit der Byte-Adresse als Hex-Wert und dem Takt in kHz instanziiert. Die Taktrate mit 100 kHz reicht aus, auch wenn der Sensor den Fast Mode mit 400 kHz unterstützt. Warum nicht den Fast Mode verwenden? Zurzeit besteht im .NET Micro Framework angeblich ein Fehler, sodass einige Sensoren wie dieser nicht wirklich zuverlässig mit 400 kHz arbeiten:

 public class HTU21D : BaseDevice

{

  // Konfiguration anlegen. Adresse und Takt bei Normal Mode.

  public HTU21D(): base(0x40, 100){ }

  // ...

Für den Anfang braucht der Sensor keine Initialisierung, und bereits mit den Standardeinstellungen können die Daten richtig gemessen werden. Falls Sie eine Anforderung haben, z. B dass Messungen schneller erfasst werden sollen, dann ist Init zu überschreiben. Die eigentliche Abfrage des Sensormoduls kommt nun mit der Methode SetMessureResults. In das SensorData-Objekt werden die Ergebnisse eingetragen. Listing 3 zeigt, dass zuerst eine gemeinsame Methode die Rohwerte der Messungen ausliest und dann in den Variablen zwischenspeichert. Wenn diese Werte 0 sind, liegt ein Abfrageproblem vor, womit dann die Eigenschaft IsMessureValid auf false zu setzen ist. Die letzten zwei Zeilen rechnen die Rohwerte mit CalculateValue um.

 public sealed override void SetMessureResults(ref I2CDevice i2c, 
  ref SensorData data)
{
  // Rohwert abholen für Luftfeuchtigkeit
  int rawHum = this.ReadSensorPart(ref i2c, 0xf5);
  // Rohwert abholen für Temperatur 
  int rawTem = this.ReadSensorPart(ref i2c, 0xf3);
  
  if (rawHum == 0 || rawTem == 0) { data.IsMessureValid = false; }

  data.Humidity = this.CalculateValue(rawHum, -6f, 125f);
  data.Temperature = this.CalculateValue(rawTem, -46.85f, 175.72f);
}

Der Vorgang für die Feuchtigkeitsmessung ist derselbe wie für die Messung der Temperatur. Nur das Register-Byte macht den Unterschied im Programmcode. In Listing 4 ist zu sehen, dass hier angehalten wird, wenn über den I²C Bus nicht geschrieben werden kann. Dies kann dann auftreten, wenn etwa die Kabelverbindung zum Sensor getrennt ist.

 private int ReadSensorPart(ref I2CDevice i2c, byte command)
{
  // Befehl senden
  if (this.Write(ref i2c, new byte[] { command }) == 0)
  {
    throw new SystemException("Fehler beim Senden!");
  }

  // Warten, bis der Sensor mit dem Lesevorgang fertig ist
  Thread.Sleep(50);

  byte[] ba = new byte[3];

  // Messergebnis abrufen
  if (this.Read(ref i2c, ba) == ba.Length)
  {
    uint raw = ((uint)ba[0] << 8) | (uint)ba[1];
    raw &= 0xFFFC;
    return (int)raw;
  }

  Debug.Print("Sensor konnte nicht gelesen werden!");
  return 0;
}

Die Umrechnung in einen verwendbaren Wert benötigt nicht nur den Rohwert, sondern einen Start-(start)- und einen Reichweite-(range) Wert:

 private float CalculateValue(int raw, float start, float range)
{
  // Gemessenen Rohwert durch den maximalen Ausgabewert
  float f = (float)raw / 65536f;
  
  // Messumfang multiplizieren und mit dem Startwert summieren
  return start + (range * f);
}

Luftdrucksensor mit Startkalibrierung

Der Sensor BMP085 stellt den größten Teil der kleinen Wetterstation dar. Der Umfang geht so weit, dass zunächst eine Hilfsklasse BMP085Helper angelegt wird. Die vielen angelegten Variablen sind für die Kalibrierungskoeffizienten gedacht, die für die korrekte Berechnung des Luftdrucks notwendig sind:

 
// Empfangspuffer für Luftdruck
public byte[] ReceiveP = new byte[3], ReceiveT = new byte[2];
// Kalibrierungswerte - Kalibrierungskoeffizienten
public short AC1, AC2, AC3, B1, B2, MB, MC, MD, OSS = 0;
public uint AC4,  AC5, AC6;
private long _B5;

Für das Ergebnis des Luftdrucks muss vom Sensor zu Beginn die Temperatur berechnet werden. Während dieses Vorgangs wird ein Zwischenergebnis festgelegt (_B5), mit dem sich später der Luftdruck korrekt ausrechnen lässt:

 public double GetTemperatur(ulong ut)
{
  long x1 = (((long)ut - this.AC6) * this.AC5) >> 15;
  long x2 = (((long)this.MC) << 11) / (x1 + this.MD);      this._B5 = x1 + x2;   return (double)((this._B5 + 8) >> 4) / 10;
}

Zugegeben, die Methode GetPressure mit den zahlreichen Berechnungen wirkt etwas abschreckend und vielleicht fragt sich der eine oder andere, ob das so sinnvoll ist. Tatsächlich ja, denn zumindest im Datenblatt ist der Vorgang so beschrieben (Listing 5).

 
public long GetPressure(ulong up)
{
  long b6 = this._B5 - 4000;
  long x1 = (this.B2 * (b6 * b6) >> 12) >> 11;
  long x2 = (this.AC2 * b6) >> 11;
  long x3 = x1 + x2;
  long b3 = (((((long)this.AC1) * 4 + x3) << this.OSS) + 2) >> 2;
  x1 = (this.AC3 * b6) >> 13;
  x2 = (this.B1 * ((b6 * b6) >> 12)) >> 16;
  x3 = ((x1 + x2) + 2) >> 2;
  ulong b4 = ((ulong)this.AC4 * (ulong)(x3 + 32768)) >> 15;
  ulong b7 = ((ulong)(up - (ulong)b3) * (ulong)(50000 >> this.OSS));
  long p = 0;
  if (b7 < 0x80000000)
  {
    p = (long)((b7 << 1) / b4);
  }
  else
  {
    p = (long)((b7 / b4) << 1);   }   x1 = (p >> 8) * (p >> 8);
  x1 = (x1 * 3038) >> 16;
  x2 = (-7357 * p) >> 16;
  p += (x1 + x2 + 3791) >> 4;
  return p;
}

Nun der eigentliche Teil der Klasse BMP085 für das Einlesen des Luftdrucksensors: Für den allgemeinen Zugriff innerhalb der Klasse wird von der zuvor angelegten Hilfsklasse eine Instanz benötigt. Der Konstruktor erhält für die Basis die Adresse und Taktrate:

 public class BMP085 : BaseDevice
{
  private BMP085Helper _Helper = new BMP085Helper();
  public BMP085() : base(0x77, 100) { }
  // ...

In Listing 6 wird diesmal die Init-Methode aus der Basis überschrieben, in ihr sollen dann auch die Koeffizienten abgerufen und anschließend in der Hilfsklasse hinterlegt werden.

 public override void Init(ref I2CDevice i2c)
{
  base.Init(ref i2c);

  _Helper.AC1 = (short)GetCalibrateValue(ref i2c, new byte[] {0xAA,0xAB}, false);
  _Helper.AC2 = (short)GetCalibrateValue(ref i2c, new byte[] {0xAC,0xAD}, false);
  _Helper.AC3 = (short)GetCalibrateValue(ref i2c, new byte[] {0xAE,0xAF}, false);
  _Helper.AC4 = (uint)GetCalibrateValue(ref i2c, new byte[] {0xB0,0xB1}, true);
  _Helper.AC5 = (uint)GetCalibrateValue(ref i2c, new byte[] {0xB2,0xB3}, true);
  _Helper.AC6 = (uint)GetCalibrateValue(ref i2c, new byte[] {0xB4,0xB5}, true);
  _Helper.B1 = (short)GetCalibrateValue(ref i2c, new byte[] {0xB6,0xB7}, false);
  _Helper.B2 = (short)GetCalibrateValue(ref i2c, new byte[] {0xB8,0xB9}, false);
  _Helper.MB = (short)GetCalibrateValue(ref i2c, new byte[] {0xBA,0xBB}, false);
  _Helper.MC = (short)GetCalibrateValue(ref i2c, new byte[] {0xBC,0xBD}, false);
  _Helper.MD = (short)GetCalibrateValue(ref i2c, new byte[] {0xBE,0xBF}, false);
}

Der Vorgang für den Abruf eines Koeffizienten ist mit wenigen Ausnahmen immer derselbe. Klar zu sehen ist die Verwendung einzelner Register und des Rückgabetyps. Listing 7 zeigt: Das erste Byte aus dem Byte Array hat das Register für die Abfrage und das zweite Byte steht für den Einlesevorgang. Zum Schluss werden die zwei Bytes in ba abgelegt und per Byte Shifting zu einem Wert zusammengefügt.

 private object GetCalibrateValue(ref I2CDevice i2c,byte[] msb_lsb, bool isUnsigned)
{
  byte[] ba = new byte[] { msb_lsb[1], 0x00 };
  // Abfrage starten
  if (Write(ref i2c, new byte[] { msb_lsb[0] }) == 0)
  {
    Debug.Print("Abfrage konnte nicht erfolgreich gesendet werden.");
  }
  // Ergebnis einlesen
  else if (Read(ref i2c, ba) == 0)
  {
    Debug.Print("Abfrage konnte nicht erfolgreich empfangen werden.");
  }
  else
  {
    // die zwei Bytes zu einem Wert zusammenfügen
    if (isUnsigned)
    {
      return (uint)(ba[0] << 8 | ba[1]);
    }
    else
    {
      return (short)(ba[0] << 8 | ba[1]);
    }
  }
  return 0;
}

Wie bereits beim Auslesen des Feuchtigkeitssensors wird auch beim Luftdruck die Methode SetMessureResults überschrieben(Listing 8).

 public sealed override void SetMessureResults(ref I2CDevice i2c, 
  ref SensorData data)
{
  // Die Temperatur muss mit ausgelesen werden, 
  // um im Anschluss den richtigen Luftdruck zu berechnen.
  ulong rawTemp = this.GetTemperaturRawValue(ref i2c);
  ulong rawPres = this.GetPressureRawValue(ref i2c);

  // Wenn die Messung nicht in Ordnung ist
  if (rawPres == 0 || rawTemp == 0) { data.IsMessureValid = false; }

  data.Pressure = this._Helper.GetPressure(rawPres);
}

Der Abruf von Temperatur und Luftdruck unterscheidet sich beim Senden, Lesen und zu guter Letzt bei der Zusammenführung der Bytes zu einem Wert. In Listing 9 wird mit zwei Registerbytes an den I²C Bus geschrieben, dann wird fünf Millisekunden lang gewartet. Mit der Methode CheckResult wird der Sensor nochmal angesprochen und ausgelesen (Listing 10).

 private ulong GetTemperaturRawValue(ref I2CDevice i2c)
{
  // Statusabfrage senden
  if (Write(ref i2c, new byte[] { 0xF4, 0x2E }) == 0)
  {
    Debug.Print("Status Abfrage für Temperatur fehlerhaft.");
    return 0;
  }

  // Kurz abwarten
  Thread.Sleep(5);

  // Ergebnis abrufen und prüfen
  if (!CheckResult(ref i2c, new byte[] { 0xF6, 0xF7 }, 
    ref this._Helper.ReceiveT)) { return 0; }

    return (ulong)this._Helper.ReceiveT[0] << 8 | this._Helper.ReceiveT[1];
}
 private bool CheckResult(ref I2CDevice i2c, byte[] commands, ref byte[] receive)
{
  bool b = true;
  if (Write(ref i2c, commands) == 0)
  {
    Debug.Print("Ergebnis abfrage konnte nicht gesendet werden.");
    b = false;
  }

  if (Read(ref i2c, receive) == 0)
  {
    Debug.Print("Es konnte kein Wert gelesen werden.");
    b = false;
  }
  return b;
}

Kommen wir zur letzten Methode GetPressureRawValue (Listing 11). Bereits vor dem Senden werden Bytewerte für die Einstellung summiert, wobei einer zuvor noch geshiftet wird. Das OSS (Oversampling) kann in der Hilfsklasse geändert werden, wenn eine genauere Messung erforderlich ist. Derzeitig wird mit dem Wert „0“ der „Ultra-Low-Power“-Mode gesetzt, der für die Anwendung ausreicht. Der OSS-Wert „3“ stellt den höchsten einstellbaren Mode für „Ultra High Resolution“ dar. Als Hinweis für diese Einstellung ist in der Hilfsklasse die Berechnung des Luftdrucks noch einmal anzupassen, da sie aktuell noch nicht dafür ausgelegt ist. Empfehlenswert ist der Blick in das Datenblatt [3].

 private ulong GetPressureRawValue(ref I2CDevice i2c)
{
  // Statusabfrage senden
  if (Write(ref i2c, 
  new byte[] { 0xF4, (byte)(0x34 + (this._Helper.Mode << 6)) }) == 0)
  {
    Debug.Print("Status Abfrage für Luftdruck fehlerhaft.");
    return 0;
  }
  
  // Kurz abwarten
  Thread.Sleep(8);
  
  // Sensorergebnis abfragen
  if (!CheckResult(ref i2c, 
  new byte[] { 0xF6 }, ref this._Helper.ReceiveP)) { return 0; }
  
  // Ergebnis per Byteshifting zusammenlegen
  return (ulong)((this._Helper.ReceiveP[0] << 16) | 
  (this._Helper.ReceiveP[1] << 8) |    (this._Helper.ReceiveP[2])) >> (8 - this._Helper.OSS);
}

Der Hauptprogrammteil findet sich in der Klasse Program (Listing 12). Der Inhalt gleicht fast der einer Konsolenanwendung und lässt sich praktisch auch so verwenden. Falls hier der Namespace Microsoft.SPOT.Hardware noch nicht unter den usings zu finden ist, sollte er nachgetragen werden. Für die Verbindung wird nun eine Instanz der I2CDevice-Klasse benötigt, die wiederum nur eine Instanz der Schnittstelle zulässt. Die Adresse soll keinem Ziel zugeordnet sein und der Takt wird bei 100 kHz festgelegt, da dieser erst beim Durchreichen der Methoden aus den zwei Sensorklassen die vorgesehenen Einstellungen erhält.

 public class Program
{
  public static void Main()
  {
    // Adresse, Takt Normal Mode
    I2CDevice device = new I2CDevice(
    new I2CDevice.Configuration(0x00, 100));

    // Sensorklassen 
    HTU21D htu21d = new HTU21D();
    BMP085 bmp085 = new BMP085();
    bmp085.Init(ref device);

    SensorData data = new SensorData();

    while (true)
    {
      data.IsMessureValid = true;

      bmp085.SetValues(ref device, ref data);
      htu21d.SetValues(ref device, ref data);

      Debug.Print(data.ToString());
      Thread.Sleep(1000);
    }
  }

}

Das Programm ist an und für sich fertig und kann im nächsten Schritt geprüft werden, indem man F5 drückt und wartet, bis das Programm auf dem Netduino geschrieben wurde. Zuvor sollte die Schaltung bereits aufgebaut sein. Rufen Sie im Emulator schließlich die Eigenschaften des Projekts und im Seitenmenü das .NET Micro Framework auf, um die Transportbereitstellung auf USB umzustellen (Abb. 1). Der Netduino dürfte anschließend automatisch erkannt werden.

Abb. 1: In den Projekteigenschaften wird der Transportweg auf USB umgestellt

Abb. 1: In den Projekteigenschaften wird der Transportweg auf USB umgestellt

Schaltbild

Die Hardware kann fertig zusammengesteckt werden wie in Abbildung 2 beim Netduino 1 zu sehen. Beim Netduino 2 (Abb. 3) sind die Anschlusskontakte auf der anderen Seite mit den Pin-Beschreibungen SDA und SCL zu finden.

Abb. 2: Netduino 1

Abb. 2: Netduino 1

Abb. 3: Netduino 2

Abb. 3: Netduino 2

Je nach Ausführung des HTU21D, aber auch bei anderen Sensoren mit I²C-Verbindungen werden zusätzlich zwei Widerstände als Pull-up benötigt. Es gibt diese Module allerdings auch mit bereits aufgelöteten Pull-ups, sodass Sie diese auslassen können. Lässt sich das Programm dann ohne Weiteres auf den Netduino schreiben, dürften Sie das Ergebnis aus Abbildung 4 sehen.

Abb. 4: So sieht das Ergebnis aus

Abb. 4: So sieht das Ergebnis aus

Fazit

Das Schöne an unserer Vorgehensweise ist, dass die Visual-Studio-Entwicklung unterstützt wird. Mit relativ wenig Aufwand kann schnell ein Ergebnis erzielt werden, ohne dass tiefe Kenntnisse in der Elektrotechnik benötigt werden. Der Netduino eignet sich gut für den Einstieg. Wer jedoch das Potenzial des ARM-7- oder ARM-9-Mikrocontrollers ausschöpfen möchte, der sollte sich die Gadgeteer-Serie von GHI Electronics [5] ansehen. Eine der besonderen Schnittstellen stellt die Unterstützung für kleine Bildschirme bereit, womit die Entwicklung mit dem .NET Micro Framework und WPF überhaupt erst möglich wird.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -