Eigene Funkprotolle

Über diesen Beitrag

In den letzten Beiträgen habe ich vorgestellt wie man mittels geeigneter Bibliotheken Funkmodule und Funksteckdosen mit dem Arduino oder anderen Microcontrollern ansteuert. Aber geht 433 MHz Funk auch ohne Bibliothek? Ist es kompliziert, eigene (einfache) Funkprotokolle zu entwickeln? Wenn man ein paar Dinge beachtet, ist es nicht sonderlich schwierig und macht darüber hinaus auch viel Spaß. Ich stelle hier zwei Konzepte vor. Dabei benutze ich einfache 433 MHz Transmitter- und Receivermodule wie in den vorherigen Beiträgen. Bitte nicht vergessen Antennen an die Module zu basteln. Ein Anschlussschema für den Arduino muss ich wohl nicht noch einmal abbilden: GND an GND, VCC an 5 V und den Datapin des Moduls gemäß Vorschrift im jeweiligen Sketch. Für die Transmitterseite habe ich einen Arduino Nano eingesetzt, auf der Receiverseite einen UNO. 

Einfache „One-Way“ Module für 433 MHz

Schnelligkeit ist gefragt

Bevor es losgeht muss ich noch ein kleines bisschen ausholen. Funktechnik ist so schnell, dass die Geschwindigkeit, in der ein Pinstatus (HIGH oder LOW) abgefragt werden kann, durchaus relevant wird. Normalerweise verwendet man den digitalRead Befehl, hier z.B. zum Abfragen des Pin 5:

pinStatus = digitalRead(5);

Gleichbedeutend, wesentlich schneller, aber auch viel kryptischer ist die „C“ Schreibweise:

pinStatus = PIND & (1<<PD5);

Ich werde vielleicht in einem anderen Beitrag auf das Thema „C“ näher eingehen, denn es gibt auch viele andere Gelegenheiten, wo die „C“ Schreibweise sinnvoll ist. Hier werde ich nur kurz erklären, was das bedeutet. „PD5“ ist eine vordefinierte Konstante mit dem Wert 5 und steht für den Pin 5 am PORTD des ATmega 328. Dieser Pin ist zufälligerweise der Digitalpin 5 am Arduino. Der Pin „PB5″ ist beispielsweise Digitalpin 13 am Arduino. „1<<PD5“ ist eine Binäroperation und bedeutet: verschiebe die 1 um fünf Stellen nach links. Also wird aus der 1, sprich der 00000001, wird eine 00100000. 

PIND ist das Statusregister des PORTD. Sind alle Pins „LOW“ steht im PIND Register „00000000“. Ist nur PD5 „HIGH“, steht dort „00100000“. „&“ ist ein binäres „UND“. Es vergleicht bitweise beide Werte links und rechts von sich. Sind beide gleich, ist das Ergebnis „TRUE“ bzw. „1“. Sind beide ungleich, ist das Ergebnis „FALSE“ bzw. „0“. Und damit führen beide oben genannten Befehle zum selben Ergebnis. 

Wieviel schneller die „C“ Schreibweise ist, testet der folgende Sketch. Ich habe den Status des Pin 5 mit beiden Befehlen 50000 Male auslesen lassen und die dafür benötigte Zeit in Millisekunden messen lassen. Hier der Sketch und das Ergebnis:

unsigned int numberOfReads = 50000;
long int startTime = 0;
long int readDuration = 0;
bool pinStatus;

void setup() {
  Serial.begin(9600); 
  pinMode(5,INPUT); 
}

void loop() {
  startTime = millis();
  for(unsigned int i=0; i<numberOfReads; i++){
    pinStatus = digitalRead(5);
  }
  readDuration = millis() - startTime;
  Serial.print("digitalRead: ");
  Serial.print(readDuration);
  Serial.print(" / ");
  delay(1000);
  
  startTime = millis();
  for(unsigned int i=0; i<numberOfReads; i++){
    pinStatus = (PIND & (1<<PD5));
  }
  readDuration = millis() - startTime;
  Serial.print("In C: ");
  Serial.println(readDuration);
  delay(5000);
}

Demnach ist der „C“ Befehl zehnmal schneller. Pro Einzelabfrage ergeben sich 3.1 Mikrosekunden gegenüber 0.32 Mikrosekunden.

Viel Rauschen im Äther

Eigentlich klingt es zunächst einfach: Auf der Transmitterseite führt ein „HIGH“ am Datenpin zum Senden eines Signals. Dadurch wird an der Receiverseite ein „HIGH“ am Datenpin erzeugt und das lässt sich hinsichtlich der Länge auswerten. Wir wenden hier also keine Modulationsverfahren an. Einfach, aber funktioniert. 

Der folgende Sketch für die Receiverseite fängt Signale auf und misst deren Länge am Pin 5. Um Zeiten zu messen, verwenden die meisten wohl gerne den „millis()“ oder „micros()“ Befehl. Zum Messen sehr kurzer Zeiträume hat sich eine andere Methode bewährt. In einer Schleife wird die Bedingung für die Messung abgefragt. Solange die Abbruchbedingung nicht erfüllt ist, wird in der Schleife eine kurze Zeit (die Auflösung / Resolution) gewartet und dann ein Zähler inkrementiert. Am Ende ergibt Zähler x Auflösung die Zeit. 

const int resolution = 5;
unsigned int counter; 

void setup(){
  Serial.begin(9600);
  DDRD = 0x00; // Arduino Pins D0 - D7 als Input, "C" Schreibweise
  PORTD = 0x00; // Arduino Pins D0 - D7 sind LOW, "C" Schreibweise
}

void loop(){
  counter = 0;
  while(!(PIND & (1<<PD5))){/* Wait for High*/}
  while((PIND & (1<<PD5))){ /* Count High */
    delayMicroseconds(resolution);
    counter++;
  }
  Serial.print(counter);
  Serial.print(", "); 
}

Lässt man den Sketch laufen ohne dass man einen Sender eingeschaltet hat, ist erstaunt man wie viele Signale trotzdem detektiert werden. Wie soll man in dieser Suppe aber nun sein „echtes“ Signal finden? 

„Grundrauschen“

Das Prinzip

Man kann ein Signal als „echtes“ Signal kennzeichnen indem man es in ein eindeutig identifizierbares Start- und Endsignal einbettet. In meinem ersten Testsignal sind dies überlange „HIGH“ Dauersignale von 10000 µs. Dazwischen finden sich die „HIGH“ Informationssignale von 400, 600, 800,….., 2000 µs. Diese sind durch „LOW“ Phasen von 600 µs separiert. 

Der Sketch dazu sieht folgendermaßen aus und braucht glaube ich keine weitere Erklärung:

int txPin =  10; /* Transmitter Daten Pin */
const int lowLength = 600; /* konstante Low Phase */ void setup(){ pinMode(txPin, OUTPUT); } void loop(){ unsigned int mcs; startSequence(); /*Sende die Startsequenz*/ for(int i=0; i<9; i++){ /* Sende neun Signale; High: 400, 600, .... */ digitalWrite(txPin, HIGH); mcs = i*200 + 400; delayMicroseconds(mcs); digitalWrite(txPin,LOW); delayMicroseconds(lowLength); } endSequence(); /* Sende die Endsequenz */ delay(5000); } void startSequence(){ digitalWrite(txPin, HIGH); delayMicroseconds(10000); digitalWrite(txPin, LOW); delayMicroseconds(lowLength); } void endSequence(){ digitalWrite(txPin, HIGH); delayMicroseconds(10000); digitalWrite(txPin, LOW); }

Kommen wir zum Receiversketch. Der Receiver wartet zunächst auf ein gültiges Startsignal. Ist das gegeben, also eine bestimmte Länge überschritten, werden die Informationssignale analysiert, d.h. im Wechsel werden die Längen der „HIGH“ und „LOW“ Phasen gemessen. Diese Werte werden in einem zweidimensionalen Array „signalLength“ als „lowCounter“ und „highCounter“ gespeichert. Die Länge der Signale ist dann low- bzw. highCounter x Resolution (hier 20 µs). Wenn der „highCounter“ eine definierte Größe überschreitet, so handelt es sich um das Endesignal. Die Sequenz ist damit vollständig übermittelt und wird auf dem seriellen Monitor ausgegeben. 

int resolution = 20;

void setup(){
  Serial.begin(9600);
  DDRD = 0x00; // Arduino Pins D0 - D7 als Input
  PORTD = 0x00; // Arduino Pins D0 - D7 sind LOW
}

void loop(){
  waitForSignal();
}

void waitForSignal(){
  while(!(PIND & (1<<PD5))){/*Warte bis ein Signal kommt*/}
  if(startSequence()){      /* Ist das Signal die Startsequenz? */
    analyseSignal();        /* Wenn ja, dann analysiere das Signal */
  }
}
  
void analyseSignal(){
  int signalCounter = 0; /* Zähler für die High/Low Signalpaare */
  int lowCounter = 0;    /* Low Signallänge = lowCounter * resolution; */
  int highCounter = 0;   /* High Signallänge = highCounter * resolution; */
  int signalLength[15][2];  /* Die Signale werden in einem zweidimensionalen array gespeichert */
  bool msgCompleted = false;

  while(!msgCompleted){ /* Solange wir uns in einer Sequenz befinden */
    lowCounter = 0;
    highCounter = 0; 
    
    while(!(PIND & (1<<PD5))){ /* Warten auf High, messen des Low Signals */
      delayMicroseconds(resolution);
      lowCounter++;
    }
    while(PIND & (1<<PD5)){   /* Warten auf Low, messen des High Signals */
      delayMicroseconds(resolution);
      highCounter++;
    }
    if(highCounter < 200){    /* Ein Low/High paar wurde empfangen */ 
      signalLength[signalCounter][0] = lowCounter;
      signalLength[signalCounter][1] = highCounter;
      signalCounter++;
    }
    else if(highCounter < 5000){ /* Ende Signal empfangen, das if ist redundant */
      msgCompleted = true;
    }
  }
  /* Es folgt die Ausgabe */
  Serial.println("low / high");
  for(int i=0; i<signalCounter; i++){
    Serial.print(signalLength[i][0]);
    Serial.print(" / ");
    Serial.println(signalLength[i][1]);
  }
  Serial.println("--------");
  delay(2000);
}

bool startSequence(){
  int counter = 0;
  while(PIND & (1<<PD5)){
    delayMicroseconds(resolution);
    counter++;
  }
  if(counter >400){ /* Das Startsignal wurde empfangen */
    return true;
  }
  else{
    return false;
  }
}
Das Testsignal auf der Receiverseite

Und, siehe da, man erhält schöne unterscheidbare Signale. Da die Auflösung 20 µs beträgt und die „LOW“ Signale 600 µs lang sind müsste der lowCounter 30 betragen. Genau genommen 29, da er bei Null los zählt. Wir messen also um die 100 µs zu viel. Und das, was bei den „LOW“ Signalen zu viel ist, fehlt bei den „HIGH“-Signalen. Wahrscheinlich brauchen die Receivermodule eine gewisse Zeit um bei Signalempfang den Datenpin auf „HIGH“ zu setzen. Auf jeden Fall bekommt man hier guten Eindruck in welchen Geschwindigkeiten man Daten übertragen kann. 

Protokoll 1 – bitweise Übertragung

Die Grundidee

Da wir nun gesehen haben, dass man Informationen über die Signallänge übermitteln kann, ist es nun an der Zeit eigene Funkprotokolle zu entwickeln. Im ersten Beispiel übertragen wir Bits, die über die „HIGH“ Phasenlänge definiert werden. Eine „0“ ist ein 600 µs Signal und eine „1“ ein 1200 µs Signal. Dazwischen gibt es feste „LOW“ Signale von 600 µs. Nimmt man mal an, dass Nullen und Einsen gleich oft vorkommen, dann ergibt sich eine durchschnittliche Signallänge von 1500 µs, d.h. eine Übertragungsrate von „sagenhaften“ 0,666 kbit/s. Durch das Grundlagenexperiment weiter oben haben wir gesehen, dass auch sehr viel kürzere Signale noch eindeutig unterschieden werden können. Hier geht also noch ordentlich was in Sachen Geschwindigkeit. 

Der Transmittersketch

Die zu übermittelnde Information habe ich als String definiert. Einen String kann man zunächst sehr bequem in seine einzelnen Zeichen zerlegen: euerString[ites Zeichen]. Für die Zerlegung der Zeichen in ihre 8 Bits verwende ich eine Binäroperation in der Funktion „sendeByte“, die Ihr – wenn Ihr oben gut aufgepasst habt – verstehen solltet. In Kurzform: Das Byte wird schrittweise nach rechts verschoben. Dann wird mit dem logischen „UND“ geprüft, ob das jeweils erste Bit „1“ oder „0“ ist. Entsprechend wird dann ein kurzes oder langes „HIGH“ in der Funktion sendeSignal() gesendet. Das Ende Signal habe ich auf 5000µs gekürzt.

int txPin =  10; /* Transmitter Data Pin */

void setup() {
  pinMode(txPin, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  String msgString = "Hallo Welt, wie geht es dir heute?";
  sendeString(msgString);
  delay(5000);
}

void sendeString(String msg) {
  int len = msg.length();
  initMsg();  /* Sende das Initialisierungssignal */
  for (int i = 0; i < len; i++) { /* Byte by byte senden */
    sendeByte(byte(msg[i]));
  }
  closeMsg();
}

void sendeByte(byte msgByte) { /*Bit by bit senden */
  bool msgBit = 0;
  for (int i = 7; i >= 0; i--) { /* "Extrahieren" der bits */
    msgBit = (msgByte >> i) & 1; 
    sendeSignal(msgBit);
  }
}

void sendeSignal(bool sendBit) {
  digitalWrite(txPin, HIGH);
  if (sendBit == false) {
    delayMicroseconds(600);
  }
  else {
    delayMicroseconds(1200);
  }
  digitalWrite(txPin, LOW);
  delayMicroseconds(1000);
}

void initMsg() {  /* Initialisierung */
  digitalWrite(txPin, HIGH);
  delayMicroseconds(10000);
  digitalWrite(txPin, LOW);
  delayMicroseconds(600);
}

void closeMsg() { /*Abschlusssignal */
  digitalWrite(txPin, HIGH);
  delayMicroseconds(5000);
  digitalWrite(txPin, LOW);
  delayMicroseconds(600);
}

Der Receiversketch

Der Receiversketch ähnelt sehr dem Receiversketch für das Testsignal.  Zunächst wird auf ein gültiges Startsignal gewartet, dann werden die Signallängen ausgewertet und als Bits interpretiert. Die Bits werden über Binäroperationen zu Bytes zusammengesetzt, die Bytes in Characters umgewandelt und aus diesen der String wieder zusammen gesetzt. Da nur ganze Bytes übertragen werden, sollte am Ende kein Bit mehr übrig sein. Und wenn doch, dann ist irgendetwas schief gegangen und ein Error Flag wird gesetzt.  

int resolution = 20; /* Auflösung */
int error = 0;       /* Fehler flag */

void setup(){
  Serial.begin(9600);
  DDRD = 0x00;
  PORTD = 0x00; 
  Serial.println("Warte auf Signal...");
}

void loop(){
  listenToSignal();
}


void listenToSignal(){
  int counter = 0;
  int lowPulse = 0;
  int highPulse = 0;
  byte bitNo = 0;
  byte incomingByte = 0;
  String msg = "";
  int msgLen = 0;
  bool msgCompleted = false;
  error=0;
  
  while(!(PIND & (1<<PD5))){/*Wait*/}
  if(startSequence()){
       
    while(!msgCompleted){
      lowPulse = 0;
      highPulse = 0;
      
      while(!(PIND & (1<<PD5))){
        delayMicroseconds(resolution);
        lowPulse++;
      }
      while(PIND & (1<<PD5)){
        delayMicroseconds(resolution);
        highPulse++;
      }
      if(highPulse < 45){
        incomingByte = (incomingByte << 1);
      }
      else if(highPulse < 70){
        incomingByte = (incomingByte << 1) + 1;   
      }
      else if(highPulse >= 150){
        msgCompleted = true;
        if(bitNo!=0){
          error = 1; 
        }
      }
      if(bitNo<7){
        bitNo++;
      }
      else{
        msg = msg + char(incomingByte);
        bitNo = 0;
        msgLen++;
        incomingByte = 0;
      }
    }
    if((!error)&&(msg.length()!=0)){
      Serial.println(msg);
    }
//    else{
//      Serial.println("error");
//    }
  }
}

bool startSequence(){
  int counter = 0;
  while(PIND & (1<<PD5)){
    delayMicroseconds(resolution);
    counter++;
  }
  if(counter > 300){
    return true;
  }
  else{
    return false;
  }
}

Und siehe da – es funktioniert!

Protokoll 2 – Zahlen ziffernweise übertragen

Die Grundidee

Vorab: Diese Idee für ein eigenes Funkprotokoll stammt noch aus meinen ersten Tagen in der Arduino Welt. Die Übertragungsgeschwindigkeit lässt sich noch erheblich steigern – vielleicht habt Ihr ja Lust das umzuarbeiten. Mir kommt es hier auf das Prinzip an. Ich verwende diese Methode in meinem selbstgebauten Funk-Grillthermometer und die Übertragung arbeitet überaus zuverlässig. 

Also, die Idee ist simpel: Eine Zahl wird in Ziffern zerlegt und die Ziffern über die Signallänge kodiert (hier im Millisekundenbereich). Durch die sehr langen Signalzeiten braucht man keinen „C“ Code. Damit man Nullen übertragen kann, bekommt jedes Signal noch ein Offset. Anstelle der 0 wird eine 1 übertragen, anstelle der 2 eine 3, usw. Auf der Receiverseite wird das wieder herausgerechnet. 

Da die Signale extrem lang sind, heben sie deutlich sich vom Grundrauschen ab. Ein Startersignal ist entsprechend nicht erforderlich. 

Die Transmitterseite

Ich sende drei festgelegte Ziffern vorab als eine Art Sicherheitscode. Wird dieser nicht erhalten, so ist das Signal ungültig. Ansonsten dürfte der Sketch selbsterklärend sein. 

int txPin =  10; /* Transmitter Data Pin */
byte ziffer[10];
unsigned long number = 79631956; /* zu sendende Zahl */
int digits; /* Stellen: hier 10stellige Zahl */
 
void setup(){                
  Serial.begin(9600);
  pinMode(txPin, OUTPUT);  
  zerlegeZahl(number, digits); /* Zahl in Ziffernarray */
  Serial.println(digits);
  for(int i=digits; i>=0; i--){
    Serial.print(ziffer[i]);    
  }
}
 
void loop(){
  
  sendNumber();
  digitalWrite(txPin, LOW);
  delay(5000);
}
 
void sendNumber(){
  //"Sicherheitscode": 214
  sendeSignal(2);
  sendeSignal(1);
  sendeSignal(4);
  for(int i=0; i<digits; i++){
    sendeSignal(ziffer[i]);
  }
}

void sendeSignal(byte laenge){
  digitalWrite(txPin, HIGH);
  delay((laenge+1)*2); /* Die Signallänge ist (Ziffer + 1) mal 2, d.h. maximal 20ms */
  digitalWrite(txPin, LOW);
  delay(5);
}

void zerlegeZahl(unsigned long zahl, int& laenge){
  laenge = 0;
  while(zahl>0){
    ziffer[laenge]=zahl%10;
    zahl=zahl/10;
    if(zahl >= 0) laenge++;
  }
}

Der Receiversketch

Zunächst habe ich hier den Datenpin des Receivers an den Analogpin A0 gelegt. Ein Signal wird als „HIGH“ Signal interpretiert wenn am Datenpin eine Mindestspannung erreicht wird. Der Faktor für die Umrechnung der Signallänge in die Ziffern wurde empirisch ermittelt. In der großen do…while Schleife wird zunächst überprüft ob eine Abbruchbedingung vorliegt. Das könnte eine Gesamtzeitüberschreitung sein oder wenn die Zeit zwischen zwei Signalen überschritten wird, was als Ende der Signalübertragung gewertet wird. Zu kurze Signale werden als Störsignal verworfen. Am Ende wird erst der Sicherheitscode herausgerechnet und aus der Ziffernfolge die übertragene Zahl rekonstruiert. 

int rxPin = A0; /* Receiver Data Pin an Analogpin 0 */
const float calcFactor = 12.5; /* Umrechnungsfaktor Signallänge in Zahl */

void setup(){
  Serial.begin(9600);
  Serial.println("Los geht's....");
}

void loop(){
  listenForNumber();
}

unsigned long listenForNumber(){
  int maxDigit=-1; /* Gibt die Stellen der Zahl an */
  unsigned long number=0; 
  int digit[20];
  unsigned long listenBeginTime;
  unsigned long maxListenTime = 60000;
  unsigned long lastSignalTime = 0;
  const int maxBetweenSignalTime = 150;
  for(int i=0; i<3; i++) digit[i]=0;
  int threshold = 300;  // ein Analogread über 300 gilt als Wesswert
  unsigned int counter;  //Zähler für 50 Mikrosekundeneinheiten; 50 Mikrosekunden ist die Auflösung
  bool firstSignal = true;
  bool messageCompleted = false;
  bool noSignal; 
  
  listenBeginTime=millis();
  noSignal = false;
  do{
    counter = 0;
    while(analogRead(rxPin)<threshold){
      if((millis()-listenBeginTime) > maxListenTime){ /* Abbruch wg. Gesamtzeitüberschreitung */
        noSignal = true;
        break; 
      }
      if(!firstSignal && ((millis()-lastSignalTime) > maxBetweenSignalTime)){
        messageCompleted = true; /* nach 150 ms ohne Signal gilt die Übertragung als komplett */
        break;
      }
    }
    while(analogRead(rxPin)>threshold){  // Messe die Funksignallänge
      delayMicroseconds(50);
      counter++;
    }
    if(counter>9){ // nur wenn, dass Funksignal eine Mindestlänge hat, ist es kein Störsignal
      firstSignal = false; 
      lastSignalTime=millis();
      maxDigit++;
      digit[maxDigit]=((int)round(counter/calcFactor));
    }
    
  }while(!messageCompleted || noSignal);

  /* "Herausnehmen" des Sicherheitscodes */
  if((digit[0]==3&&digit[1]==2&&digit[2]==5)&&(messageCompleted==true)){
      unsigned long pot=1;
      for(int i=3; i<=(maxDigit); i++){ /* Umrechnung der relevanten Ziffern in die Zahl */ 
        number += (digit[i]-1)*pot;
        pot = pot*10;
      }
    Serial.println(number);
  }
}
Und auch diese Übertragungsmethode funktioniert

Wie gesagt, die Übertragung ist langsam, aber überaus stabil. Ich habe das Ganze noch in einer Bibliothek verpackt. Sie hat ggü. dem Code oben die Einschränkung, dass sie auf sechs Ziffern beschränkt ist. Ihr könnt die Bibliothek mit Beispielen hier herunterladen und nach Belieben verbessern.

So, das war sehr lang. Aber ich hoffe es hat Euch ein paar Anregungen gegeben. Viel Spaß wenn Ihr eigene Funkprotokolle entwickelt! Wie immer freue ich mich über Rückmeldungen. 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.