Programování | Pojďme programovat elektroniku | Arduino | Komunikace

Pojďme programovat elektroniku: Vyrobíme si laserový vysílač z primitivního konferenčního ukazovátka

  • Dost bylo nudných PowerPointů
  • Proměníme konferenční ukazovátko v „laserový komunikátor“
  • Dosáhneme rychlosti 4 kb/s

V našem seriálu Pojďme programovat elektroniku jsme k Arduinu, ESP8266 a dalším mikrokontrolerům připojili už bezpočet všemožných senzorů a modulů a oživovali je některou z dostupných knihoven.

Každý z nich ke komunikaci používal nějaký signál a my si dnes ukážeme, jak vlastně takový signál vypadá na té nejnižší úrovni, sestrojíme si totiž laserový vysílač s rychlostí několika kilobitů za sekundu. Bude nám k tomu stačit primitivní konferenční ukazovátko a fotocitlivý detektor.

Nejprve si ale zrekapitulujeme, jak pracovat s elektrickým signálem. Začneme tím nejprimitivnějším. Pokud k Arduinu připojíte třeba PIR (infračervený detektor pohybu), na jeho signálním pinu se při detekci pohybu nastaví dle jeho konstrukce buď velmi nízké, nebo naopak o něco vyšší pracovní napětí, které lze v digitální logice vyjádřit hodnotami LOW (0), nebo naopak HIGH (1).

Kód k detekci pohybu by tedy byl v Arduinu poměrně primitivní. Kdyby byl signální pin detektoru pohybu připojený na digitální pin Arduina číslo 10, mohl by ten nejjednodušší kód programu, který při detekci pohybu rozsvítí vestavěnou diodu na pinu 13, vypadat třeba takto

#define PIR 10
#define LED 13

void setup() {
  pinMode(PIR, INPUT);
  pinMode(LED, OUTPUT);
}

void loop() {
  if (digitalRead(PIR)) digitalWrite(LED, HIGH);
  else digitalWrite(LED, LOW);
}

Překlad napětí na digitální logiku souvisí samozřejmě s konstrukcí mikrokontroleru a jeho pracovním napětím. 5V Arduino tedy jako LOW interpretuje napětí zhruba v rozmezí 0-1,5 V a jako HIGH pak napětí 3-5 V.

Kdybyste chtěli pětivoltové Arduino spojit s modulem, jehož pracovní napětí bude výrazně nižší, třeba okolo 2 V, nemohli byste jej přímo připojit na některý z pinů prototypovací destičky. Nejen že byste jej mohli poškodit, ale kdyby modul vyslal signál HIGH třeba s napětím 1,9 V, pětivoltové Arduino by jej nejspíše vůbec nerozpoznalo a logika by nepracovala, jak má. Vzájemná napětí by tedy bylo třeba upravit třeba pomocí převodníků logiky.

442847104 597702024 679581629
Přechody mezi LOW a HIGH pro 5V Arduino (ATmega328), 3,3V CMOS logiku a obecnou 5V TTL logiku. Všimněte si, že limity se liší podle směru komunikace (IL, IH pro čtení a OL, OH pro zápis). Více se o logických úrovních dočtete třeba na webu learn.sparkfun.com.

Metronom který tiká a data tečou

Složitější elektronika si již s jedním komunikačním vodičem zpravidla nevystačí a používá jich hned několik. V našem seriálu jsme se setkali především se sběrnicí I2C, kterou dali světu na počátku 80. let inženýři z divize Philips Semiconductor.

U té nejdrobnější a nejjednodušší elektroniky, kde nejde až tak o přenosovou rychlost, je velmi oblíbená i po více než třiceti letech, k chodu totiž potřebuje pouze dva signální vodiče SDA a SCL. Zatímco v tom prvním tečou samotná binární data, tedy HIGH a LOW, na vodiči SCL tiká signál hodin, který v principu funguje podobně jako metronom – taktoměr, se kterým se setkal každý školák v hudební výchově.

753914833
Schéma jednoduchého zapojení tlakoměru BME280 pomocí sběrnice I2C se starší verzí Arduina. Dokumentace I2C vyžaduje v obvodu ještě tzv. pull-up rezistory, vestavěná knihovna Wire pro I2C komunikaci na Arduinu je však sepne přímo v řídícím čipu. Pro jednoduché a krátké testovací obvody s několika moduly to zpravidla stačí.

Podobné časovače najdete i u mnoha dalších komunikačních rozhraní – v případě SPI (Serial Peripheral Interface) se jedná o pin označený jako SCK nebo SCLK – a všechny do jednoho skutečně funguji stejně jako onen metronom. Udávají takt a tedy rychlost.

Na takovém vodiči se během komunikace ve velmi stabilní frekvenci střídá HIGH a LOW, a zařízení na druhé straně tedy ví, že při každém tiknutí (třeba změně z LOW na HIGH) má přečíst hodnotu z datového vodiče. Díky tomu je komunikace přesně synchronizovaná a její rychlost určuje tikot časovače.

314397510
Princip časovacího signálu na rozhraní I2C. SCL tiká konstantní rychlostí a dává tedy přijímači vědět, kdy má číst hodnoty z datového vodiče SDA. Více se o I2C dočtete třeba na Sparkfunu.
774591623
A ještě schéma komunikace na rozhraní SPI. I tady lze krásně vidět, jak časovač na SCK určuje, kdy se mají číst bity na linkách MOSI a MISO. Více si o SPI přečtete opět třeba na Sparkfunu.

Když metronom chybí

Vedle rozhraní s časovačem tu však máme i komunikaci, která nic takového nepotřebuje. Všichni bastlíři jedno z takových rozhraní používají zdaleka nejčastěji především při ladění kódu, do této kategorie totiž spadá třeba tradiční sériová linka UART, která má pouze dva vodiče pro každý směr komunikace, tedy TX pro vysílání a RX pro příjem, které je proto třeba při spojení dvou zařízení zkřížit (co je pro jednu stranu vysílání, je pro druhou příjem a naopak).

U těchto typů rozhraní si musí samotný protokol pečlivě pohlídat, aby data z bodu A do bodu B dorazila v pořádku. Pokud jsou vodiče SCK a SCL u I2C a SPI jakýmsi dirigentem, který udává tempo, pak je sériová linka sehranou kapelou, která se musí domluvit i bez něj.

Pojďme si vyrobit vlastní laserové rozhraní pomocí konferenčního ukazovátka

Každý začátečník pochopí, jak podobný signál funguje, když si vytvoří nějaký vlastní čistě softwarovou cestou. A jelikož se v našem seriálu snažím hledat alespoň občas neotřelé nápady, vytvořím komunikační rozhraní pomocí konferenčního ukazovátka! Namísto ukazování na nudné slajdy z PowerPointu bude pomocí svých vlastních bliků přenášet data rychlostí několika kilobitů za sekundu.

718994129
Fotocitlivý senzor a modul ukazovátka KY-008. Každá z cetek za dolar.

Jelikož však doma zrovna žádné ukazovátko nemám, pořídil jsem na eBayi jeho poměrně rozšířený a naprosto primitivní modul KY-008, jehož cena se pohybuje okolo jednoho dolaru. Jako přijímač jsem pak použil laserový fotocitlivý senzor, jehož cena se pohybuje také okolo dolaru, čemuž odpovídá i kvalita. To znamená, že spolehlivě bude fungovat jen v dostatečně stinném prostředí – v pravé poledne v místnosti se zataženými žaluziemi a ideálně bez blikajícího umělého světla.

462741890 134697059 768605594
Celá přenosová sestava v testu na cca 30 cm a vysílač s přijímačem v akci. Největší překážkou je přesně zamířit paprsek ukazovátka na fotocitlivou plochu.

Navrhujeme komunikační protokol

Jak senzor, tak přijímač pracuje na té nejjednodušší TTL logice jako běžná LED dioda. Abych tedy ukazovátko rozsvítil, jednoduše na jeho signálním pinu nastavím HIGH. A HIGH se poté objeví i na signálním pinu přijímače.

Fajn, teď ještě vymyslet nějaký přenosový protokol a pak už jen rychle blikat za sebou a přenášet jednotlivé bity, z nich na přijímači skládat celé bajty a v důsledku tak přenést třeba krátkou textovou zprávu v ASCII.

468458952
Že by to byl proud záblesků představující jedničky a mezery, které bych interpretoval jako nuly? Mohlo by to být rychlé, ale já na to půjdu jinak.

Mohlo by to fungovat třeba tak, že HIGH (ukazovátko svítí) bude binární 1 a LOW (ukazovátko nesvítí) bude binární 0. Stačí tedy osmkrát změnit stav, přenést 8 bitů a složit celý bajt, což by mohlo být číslo v rozmezí 0-255 nebo také jeden znak ASCII.

Skoro jako morseovka

Já na to ale půjdu trošku jinak a do stavu HIGH zakóduju jak 0, tak 1. Stav, kdy ukazovátko zrovna nesvítí, bude oddělovač mezi jednotlivými bity. Pokud se v tom už trošku ztrácíte, vězte, že na to půjdu podobně jako u morseovky, kdy informaci také nese světelný záblesk (třeba) baterky a nikoliv tma, která slouží jako oddělovač jednotlivých pulzů.

Stručně řečeno, stejně jako u morseovky rozliším typ dat délkou světleného pulzu. Ty pulzy budou ale samozřejmě mnohem rychlejší než ruka toho nejlepšího skauta!

500978368
Namísto prostého střídání stavů světla využiji princip podobný PWM. Hodnotu bitu bude určovat délka světelného pulzu a tma bude oddělovač.

Díky tomuto přístupu, který připomíná pulzně šířkovou modulaci (PWM), se nemusím vůbec starat o vzájemnou přesnou synchronizaci, jenže zároveň nebudu moci dosáhnout nikterak závratných přenosových rychlostí, protože budu muset na přijímači mezi jednotlivými záblesky spočítat jejich délku, což si samozřejmě na drobném čipu ATmega32U4, který řídí destičky Pro Micro a dnes je použiji, řekne o pořádný kus času.

Náš signál se vším všudy

Takže jak by mohlo vypadat odeslání jednoho celého bajtu o osmi bitech?

Na úplném začátku se ukazovátko rozsvítí na 1 milisekundu, poté na 100 mikrosekund zhasne, zase se na milisekundu rozsvítí a poté na 500 mikrosekund zase zhasne.

945752523
Tímto dvojpulzem dávám přijímači vědět, ať se připraví, následovat totiž budou data

Tento úvodní dlouhý dvojpulz dá přijímacímu Arduinu – které nesmí být zahlcené jinou prací – vědět, že začíná komunikace. První pulz a tedy stav HIGH může přijímač zachytit ve smyčce loop neustálou kontrolou stavu napětí na signálním vodiči z fotocitlivého senzoru (anebo pomocí přerušení). Poté začne měřit délku následujícího pulzu pomocí funkce pulseIn. Pokud zjistí, že je dostatečně dlouhý, ví že dále už budou následovat samotná data. Na přípravu k jejich příjmu má oněch 500 mikrosekund po příchodu druhého iniciačního pulzu.

void loop() {
  // Zachyceni prvni casti dvojpulzu
  if (digitalRead(PRIJIMAC)) {
    // Delka druhe casti dvojpulzu
    uint32_t delkaPulzu = pulseIn(PRIJIMAC, HIGH);
  }
}

Přijímač tedy začne ve smyčce opět měřit délku všech následujících světelných pulzů a to tak dlouho, dokud nedorazí ukončovací dlouhý pulz, který dá vědět, že přenos skončil. Jelikož funkce pulseIn disponuje na Arduinu i vlastním timeoutem, pokud v určeném maximálním čase nezachytí žádný pulz, vrátí nulový čas a přijímač bude moci ukončit z důvodu chyby jinak nekonečnou smyčku, která by blokovala Arduino až do konce věků.

Tak a teď pulzy samotných dat. Zatímco binární jedničky budou svítit 200 mikrosekund, binární nuly 100 mikrosekund. Mohly by být samozřejmě i kratší, záleží totiž pouze a jedině na výkonu vysílače a přijímače.

Na 16MHz Arduinech dokáže funkce pulseIn podle dokumentace změřit pulz o nejmenší délce pouhých 10 mikrosekund, jenže pokud je budu přenášet nejprve v elektrickém obvodu, poté opticky skrze konferenční ukazovátko, hloupoučký přijímač a následně opět elektricky, takové přesnosti reálně vůbec nedosáhnu.

346104627
Odeslání zprávy o velikosti jediného bajtu – čísla 38

Z hlediska bezchybného zpracování dat je nicméně mnohem podstatnější prodleva mezi jednotlivými pulzy, kterou jsem stanovil na 100 mikrosekund. To je doba, během které musí přijímač stihnout zachytit signál, spočítat jeho délku, rozlišit, o jaký bit se jedná a ten poté uložit do bajtu. Zároveň musí počítat, jestli už nastavil všech osm bitů, aby mohl bajt jako hotový uložit do pole a mohl začít pracovat opět s prázdným bajtem.

Skládání bajtu z jednotlivých bitů provedu pomocí funkce bitSet (a adekvátní opak bitRead). Ve skutečnosti jsou to vlastně jen makra pro překladač, která představují jednoduché operace s bitovými operátory, čili je vše maximálně rychlé. Ostatně, co se operace s jednotlivými bity týče, načal jsem je už v tomto starším dílu našeho seriálu.

500 ASCII znaků za sekundu

A to je vlastně celé. Můj postup samozřejmě nabízí některé zvláštnosti. Jelikož se signál nepřenáší jako sled stejně dlouhých pulzů, ale délka pulzu se mění v závislosti na hodnotě bitu, bude přenos bajtu s hodnotou 0 o něco rychlejší než přenos bajtu s hodnotou 255, protože v binární zápisu platí:

  • 0 = 00000000
  • 255 = 11111111

Zatímco bajt s hodnotou 0 bych přenesl za 1 600 mikrosekund (1,6 ms), bajt s hodnotou 255 za 2 400 mikrosekund (2,4 ms), protože obsahuje osm delších pulzů pro jedničkové bity.

Pokud by přenos průměrného bajtu zabral 2 ms, znamená to, že jich za sekundu přenesu asi 500. To může být třeba 500 malých čísel, anebo také 500 ASCII znaků a odpovídá to přenosové rychlosti cirka 4 kb/s.

465455958
Vlevo sériový terminál RealTerm, ke kterému je připojený vysílací mikrokontroler. Vpravo pak sériový terminál Arduino IDE, ke kterému je připojený mikrokontroler přijímače. Vysílání dle výpisu zabralo 170 milisekund a pomocí blikání ukazovátkem jsem asi na 30 centimetrů přenesl 664 bitů. Rychlost tedy dosáhla 3,895 kb/s.

Jakkoliv je tedy můj ryze studijní protokol i o pár řádů pomalejší něž běžná komunikační rozhraní na drobných prototypovacích destičkách, k přenosu typických informací vlastně naprosto dostačuje.

A co je nejdůležitější, když už takto rozblikáte primitivní ukazovátko a na počítači se napíše zpráva, kterou jste pouze svým vlastním přičiněním a bez použití jakékoliv knihovny přenesli z jednoho Arduina na druhé, spíše pochopíte, jak fungují i mnohé další a mnohem komplikovanější přenosové signály včetně Wi-Fi, ethernetu nebo třeba LTE, díky kterým jste si mohli přečíst tento článek.

Optický přenos v akci

A ještě dění na monitoru

Až se tedy budete nudit, vyrobte si vlastní přenosové rozhraní a bavte se třeba testováním, jakých rychlostí dosáhnete. S trochou píle, využíváním přímého přístupu k registrům čipu pro práci s piny aj. dosáhnete samozřejmě mnohem lepšího výkonu než v mém případě.

Na úplný závěr nesmím zapomenout na tradiční komentovaný kód vysílače a také přijímače:

Vysílač

Testováno na mikrokontroleru Pro Micro s čipem ATmega32U4.

// Knihovna pro alternativni zapis do seriove linky
#include <Streaming.h>

#define L 16 // Pin vysilace
#define MEZERA 100 // Delka mezery mezi bity v us
#define JEDNICKA 200 // Delka pulzu jednicky v us
#define NULA 100 // Delka pulzu nuly v us
#define OBALKA 1000 // Delka uvodniho/zaverecneho pulzu v us

// Funkce setup se spusti po startu
void setup() {
  // Nastartovani seriove linky
  Serial.begin(115200);
  // Nastaveni pinu „laseroveho ukazovatka“
  pinMode(L, OUTPUT);
  digitalWrite(L, LOW);
}

// Smycka loop se stale opakuje
void loop() {
  // Pokud poslu pres seriovou linku znak „s“,
  // odesle se testovaci zprava s pozdravem
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == 's') {
      char zprava[] = "Ahoj, ja jsem tvoje laserove ukazovatko a umim posilat data rychlosti okolo 4 kb/s";
      posliData(zprava, sizeof(zprava));
    }
  }
}

// Funkce pro odeslani dat skrze „laserove ukazovatko“
void posliData(uint8_t data[], uint16_t velikost) {
  Serial << "Posilam data... ";
  pulzSTART(); // Uvodni dvojpulz
  uint32_t t0 = micros(); // Ulozeni casu pro statistiku
  // Projdu bajt po bajtu zpravy k odeslani
  for (uint16_t i = 0; i < velikost; i++) {
    // Prectu postupne vsech 8 bitu kazdeho bajtu
    for (uint8_t y = 0; y < 8; y++) {
      // Pokud to bude 0, vyslu pulz pro 0
      if (bitRead(data[i], y) == 0) pulz0();
      // Pokud to bude 1, vyslu pulz pro 1
      else pulz1();
    }
  }
  uint32_t t1 = micros(); // Ulozim cas konce pro statistiku
  pulzSTOP(); // Zaverecny pulz
  Serial << " ODESLANO!" << endl << endl;

  // Vypsani statistiky do seriove linky
  float doba = (t1 - t0) / 1000.0; // ms
  float rychlost = ((1000 / doba) * (velikost * 8)) / 1000.0;
  Serial << "Statistika:" << endl;
  Serial << "Velikost dat: " << (velikost * 8) << " bitu" << endl;
  Serial << "Odeslani dat bez hlavicky trvalo: " << doba << " ms" << endl;
  Serial << "Prenosova rychlost: " << _FLOAT(rychlost, 3) << " kb/s" << endl;
}

// Vytvoreni pulzu pro bitovou jednicku
void pulz1() {
  digitalWrite(L, HIGH);
  delayMicroseconds(JEDNICKA);
  digitalWrite(L, LOW);
  delayMicroseconds(MEZERA);
}

// Vytvoreni pulzu pro bitovou nulu
void pulz0() {
  digitalWrite(L, HIGH);
  delayMicroseconds(NULA);
  digitalWrite(L, LOW);
  delayMicroseconds(MEZERA);
}

// Vytvoreni uvodniho dvojpulzu
void pulzSTART() {
  digitalWrite(L, HIGH);
  delayMicroseconds(OBALKA);
  digitalWrite(L, LOW);
  delayMicroseconds(MEZERA);
  digitalWrite(L, HIGH);
  delayMicroseconds(OBALKA);
  digitalWrite(L, LOW);
  delayMicroseconds(MEZERA * 5);
}

// Vytvoreni ukoncovaciho pulzu
void pulzSTOP() {
  digitalWrite(L, HIGH);
  delayMicroseconds(OBALKA);
  digitalWrite(L, LOW);
  delayMicroseconds(MEZERA);
}

Kód pro přijímač

Testováno na mikrokontroleru Pro Micro s čipem ATmega32U4.

// Knihovna pro alternativni zapis do seriove linky
#include <Streaming.h>

#define L 7 // Pin prijimace
#define OBALKA 1000 // Delka uvodniho/zaverecneho pulzu v us
/* Maximalni delka zpravy
  Pozor, pole zpravy je ulozene v SRAM
  1024 B je polovina cele SRAM na vetsine
  malych Arduin
*/
#define MAX 1024

uint16_t velikost = 0; // Velikost zpravy
uint8_t data[MAX]; // Pamet pro zpravu
bool prijem = false; // Aktualni stav

// Funkce setup se spusti po startu
void setup() {
  // Nastartovani seriove linky
  Serial.begin(115200);
  // Nastaveni pinu „laseroveho prijimace“
  pinMode(L, INPUT);
}

// Smycka loop se stale opakuje
void loop() {
  // Zachyceni prvni casti uvodniho dvojpulzu
  if (digitalRead(L)) {
    // Zmerim delku druhe casti dvojpulzu
    uint32_t delkaPulzu = pulseIn(L, HIGH);
    // Ted mam 500 us nez dorazi prvni datovy pulz
    // Mam tedy cas se prichystat
    uint8_t b = 0; // Aktualni bajt
    uint8_t bi = 0; // Pocitadlo jeho bitu
    /* Pokud je delka pulzu alespon 500 us,
      nemuze to byt nic jineho nez uvodni pulz.
    */
    if (delkaPulzu > (OBALKA / 2)) {
      // Menim stav na prijem a spoustim smycku
      prijem = true;
      while (prijem) {
        /* Merim stale dokola delku prichozich pulzu
          Po kazdem zmereni mam 100 us, nez dorazi
          dalsi pulz, dalsi prace tedy musi byt velmi rychla
          nebo pulz nestacim zachytit
        */
        delkaPulzu = pulseIn(L, HIGH);
        // Pri chybe (nastavitelny timeout funkce pulseIn, vychozi je 1s)
        // bude delka rovna nule, ukoncim proto smycku
        if (delkaPulzu == 0) {
          prijem = false;
          break;
        }
        // Pokud je delka mensi nez 500 us,
        // nemuze to byt nic jineho nez pulz nektereho bitu
        if (delkaPulzu < (OBALKA / 2)) {
          /* Pokud se bude jednat o pulz JEDNICKA o delce cca 200 us,
            vysledkem vypoctu 200 / 100 - 1 bude 1. Hodnotu 1 tedy
            nastavim na pozici bi aktualniho bajtu. V pripade, ze
            dorazi pulz NULA, vysledkem vypoctu bude nula, kterou do
            bajtu zapisovat nemusim, na zacatku ma totiz vsechny bity
            nastavene na 0 (bajt ma hodnotu 0).
          */
          if ((delkaPulzu / 100 ) - 1) bitSet(b, bi);
          // Zvyseni pozice bi pro pristi pulz
          bi++;
          /* Pokud je pozice bi rovna 8, uz jsem naplnil cely bajt,
            ktery nyni mohu ulozit do pole prichozich dat a anulovat
            jak citac bi, tak vychozi bajt b opet na nulu.
          */
          if (bi == 8) {
            data[velikost] = b;
            b = 0;
            bi = 0;
            // Zvyseni citace prichozich bajtu
            velikost++;
          }
        }
        /* Pokud je prichozi pulz delsi nez 500 us,
          jedna se o ukoncovaci pulz. Ukoncim tedy prijem
          a vypisu celou zpravu do seriove linky
        */
        else {
          prijem = false;
          // Vypsani tabulky jednotlivych bitu
          // prichozi zpravy pro kontrolu
          Serial << "Prichozi data RAW:" << endl;
          for (uint8_t i = 0; i < velikost; i++) {
            for (uint8_t y = 0; y < 8; y++) {
              Serial << bitRead(data[i], (7 - y));
            }
            if ((i + 1) % 4 == 0) Serial << endl;
            else Serial << " ";
          }
          Serial << endl;
          // Vypsani prichozich dat jako znaku ASCII,
          // pokud vim, ze se jedna o text
          Serial << "Prichozi data ASCII:" << endl;
          for (uint8_t i = 0; i < velikost; i++) {
            Serial << (char)data[i];
          }
          // Nastaveni delky zpravy na nulu
          velikost = 0;
        }
      }
    }
  }
}
Diskuze (8) Další článek: Týden Živě: Wi-Fi router pro IoT, kreslící A.I. a potápějící se Snap

Témata článku: , , , , , , , , , , , , , , , , , , , , , , , ,