ESP
DIYer
32
LAB

Moderní mikrokontrolér v rukou „bastlíře“

Programujeme ESP32 jako stavový automat (FSM)

Když začínáme s programováním mikrokontrolérů, většinou si vystačíme s několika podmínkami if, občasným časovačem a sem tam nějakým tím delay(). U jednodušších projektů to funguje překvapivě dobře a není důvod si život komplikovat. Jenže jakmile začne program dělat více věcí najednou, například reagovat na tlačítka, řídit výstupy, komunikovat po síti a současně sledovat nějaké senzory, začne se původně jednoduchý kód postupně měnit v nepřehlednou změť podmínek, proměnných a různých příznaků. V takové chvíli se vyplatí sáhnout po technice, která pomáhá udržet logiku programu přehlednou a srozumitelnou i ve chvíli, kdy jeho složitost postupně roste. Právě jednou z takových technik je tzv. stavový automat, často označovaný zkratkou FSM (Finite State Machine), kterému se v tomto článku pokusíme trochu věnovat.

Abychom si princip stavových automatů ukázali co nejpraktičtěji, budeme používat vývojové prostředí Arduino IDE a jako ukázkovou platformu zvolíme náš oblíbený modul ESP32. Důvod je prostý – jde o velmi rozšířenou a dostupnou platformu, kterou má mnoho bastlířů doma v šuplíku, a současně nabízí dostatek výkonu i periferií pro zajímavější projekty. Arduino IDE navíc používá velké množství začátečníků i pokročilejších uživatelů, takže se můžeme soustředit na samotný princip stavového automatu a nemusíme ztrácet čas složitostmi konkrétního vývojového prostředí. Ukázkové programy tak budou snadno pochopitelné i pro čtenáře, kteří s ESP32 teprve začínají.

UPOZORNĚNÍ:
Je důležité zdůraznit, že ačkoliv budeme používat ESP32 a jazyk Wiring, ve skutečnosti se nebudeme učit nic, co by bylo specifické právě pro tuto platformu. Stavový automat není vlastností konkrétního mikrokontroléru ani programovacího jazyka. Jde o obecný návrhový princip, který lze použít prakticky kdekoliv. Stejným způsobem můžeme navrhovat program pro Arduino UNO, STM32, Raspberry Pi Pico nebo třeba průmyslový PLC automat. Stejně tak nezáleží ani na programovacím jazyce. Pokud bychom místo Arduino IDE použili MicroPython, princip zůstane naprosto stejný. Změní se pouze syntaxe zápisu, zatímco samotná logika programu bude ve své podstatě totožná.

Na stavových automatech je typická jedna základní vlastnost: Jakmile si jednou osvojíme způsob uvažování ve stavech a přechodech mezi nimi, získáme znalost, kterou si můžeme přenést mezi různými platformami i programovacími jazyky. Není tedy důležité zapamatovat si konkrétní podobu příkazu switch nebo naučit se nazpaměť definici výčtového typu enum. Mnohem důležitější je pochopit, jak program rozdělit na jednotlivé stavy, jak definovat události, které mezi nimi přepínají, a jak díky tomu vytvořit přehledný a snadno rozšiřitelný kód. A právě na tento způsob myšlení se v následujících řádcích zaměříme.

FSM (Finite State Machine) – princip

Než se pustíme do psaní prvního stavového automatu, bude dobré si alespoň rámcově vysvětlit, o co vlastně jde. Nemusíme se ale obávat žádné složité teorie ani matematických definic. Ve skutečnosti je princip stavového automatu překvapivě jednoduchý a většina z nás se s ním setkává každý den, aniž by si to uvědomovala. Stačí se podívat třeba na semafor na křižovatce. Ten se v každém okamžiku nachází v nějakém konkrétním stavu – svítí červená, červená se žlutou, zelená nebo žlutá. Přechody mezi těmito stavy jsou přesně dané a nemohou nastat nahodile. Pokud právě svítí zelená, další stav nebude znovu zelená ani červená se žlutou, ale žlutá. Celý systém se tedy nechová podle toho, co se dělo před hodinou nebo včera, ale podle svého aktuálního stavu a událostí, které právě nastaly.

Přesně stejným způsobem můžeme uvažovat i o programu běžícím v mikrokontroléru. Místo toho, abychom vytvářeli stále složitější síť podmínek a různých pomocných proměnných, si řekneme, v jakém stavu se zařízení právě nachází. Je například vypnuté? Čeká na stisk tlačítka? Provádí měření? Připojuje se k Wi-Fi? Hlásí chybu? Jakmile máme stavy pojmenované, začíná být logika programu mnohem přehlednější. Namísto otázky „Jaké všechny podmínky musí platit, aby se provedla tato část kódu?“ si pokládáme jednodušší otázku: „V jakém stavu se právě nacházíme a kam se máme přesunout dál?“

Stavový automat tento problém řeší tím, že rozděluje chování programu na menší a lépe pochopitelné celky. V každém okamžiku přesně víme, co zařízení dělá, a také víme, za jakých okolností se jeho chování změní. Díky tomu bývá výsledný program nejen přehlednější, ale také snadněji rozšiřitelný. Když později přidáme novou funkci, často stačí doplnit nový stav nebo nový přechod mezi stavy, místo abychom zasahovali do mnoha různých částí programu současně.

POZNÁMKA:
Dobrou zprávou pro uživatele Arduino IDE je, že tento způsob programování velmi dobře zapadá do klasického modelu setup() a loop(), na který jsme zvyklí. Není potřeba instalovat žádné speciální knihovny ani se učit nový framework. Ve skutečnosti využijeme pouze to, co už dávno známe. Jednorázovou inicializaci umístíme do funkce setup() a vlastní stavový automat bude pravidelně vyhodnocován ve funkci loop(). Ta se totiž neustále opakuje dokola, což je přesně to, co stavový automat potřebuje.

První praktický příklad – tlačítko a LED

Teorie je sice hezká věc, ale mnohem lépe si princip stavového automatu ukážeme na jednoduchém příkladu. Představme si zařízení s jediným tlačítkem a LED diodou. Po zapnutí bude LED zhasnutá. Po stisku tlačítka se rozsvítí. Po dalším stisku zhasne. A takto se bude stále přepínat mezi oběma stavy.

A hned si to vyzkoušíme na nějakém vývojovém kitu s modulem ESP32 – třeba na libovolném ESP32 DEVKIT. Jako tlačítko využijeme vestavěné tlačítko BOOT, které je připojeno k pinu GPIO0 a budeme rozsvěcet vestavěnou LED, která je na pinu GPIO2.

Na první pohled jde o naprosto triviální úlohu. Možná dokonce natolik jednoduchou, že se použití stavového automatu bude zdát jako zbytečný luxus. Právě proto je ale tento příklad vhodný pro první seznámení. Můžeme se totiž soustředit pouze na princip a nebudeme se ztrácet v detailech konkrétní aplikace.

Nejprve si ukážeme řešení, které většina „začátečníků“ vytvoří zcela přirozeně.

const uint8_t LED_PIN = 2;
const uint8_t BTN_PIN = 0;

bool ledState = false;
bool lastButtonState = HIGH;

void setup() {
    pinMode(LED_PIN, OUTPUT);
    pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
    bool buttonState = digitalRead(BTN_PIN);

    if (buttonState == LOW && lastButtonState == HIGH) {
        ledState = !ledState;
        digitalWrite(LED_PIN, ledState);
    }

    lastButtonState = buttonState;
}

Jak funguje klasické řešení

Program začíná definicí pinů pro LED a tlačítko. Proměnná ledState si pamatuje aktuální stav LED diody, zatímco proměnná lastButtonState uchovává stav tlačítka z předchozího průchodu hlavní smyčkou. To je důležité proto, abychom dokázali rozpoznat okamžik stisku tlačítka a nereagovali na něj opakovaně během celé doby, kdy jej uživatel drží stisknuté.

Ve funkci setup() nastavíme LED jako výstup a tlačítko jako vstup s interním pull-up rezistorem. Díky tomu je tlačítko v klidovém stavu ve stavu logické jedničky a po stisku přechází do logické nuly. Tento způsob zapojení je v prostředí Arduino velmi běžný a umožňuje připojit tlačítko bez externích součástek.

Ve funkci loop() neustále čteme aktuální stav tlačítka. Následně testujeme, zda došlo ke změně z hodnoty HIGH na LOW. Pokud ano, znamená to, že uživatel právě tlačítko stiskl. V takovém případě pomocí operátoru negace (!) obrátíme hodnotu proměnné ledState, čímž dojde ke změně stavu LED diody. Pokud byla zhasnutá, rozsvítí se. Pokud svítila, zhasne.

Na konci každého průchodu smyčkou uložíme aktuální stav tlačítka do proměnné lastButtonState, aby bylo možné při příštím průchodu vyhodnotit případnou změnu.

Program je jednoduchý, funkční a pro tento konkrétní úkol naprosto dostačující. Můžeme všimnout, že skutečný stav zařízení je zde ukrytý v proměnné ledState. Program sice ví, zda LED svítí nebo nesvítí, ale tento stav není nijak explicitně pojmenován.

Zkusme nyní stejný problém popsat pomocí stavového automatu.

const uint8_t LED_PIN = 2;
const uint8_t BTN_PIN = 0;

enum State {
    LED_OFF,
    LED_ON
};

State state = LED_OFF;
bool lastButtonState = HIGH;

void setup() {
    pinMode(LED_PIN, OUTPUT);
    pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
    bool buttonState = digitalRead(BTN_PIN);

    switch (state) {

        case LED_OFF:
            digitalWrite(LED_PIN, LOW);
            if (buttonState == LOW && lastButtonState == HIGH)
                state = LED_ON;
        break;

        case LED_ON:
            digitalWrite(LED_PIN, HIGH);
            if (buttonState == LOW && lastButtonState == HIGH)
                state = LED_OFF;
        break;
    }

    lastButtonState = buttonState;
}

Jak funguje řešení pomocí stavového automatu?

Druhá verze programu řeší úplně stejnou úlohu, ale používá odlišný způsob uvažování. Hned na začátku definujeme globální výčtový typ State, který obsahuje dva možné stavy systému: LED_OFF a LED_ON.

Místo logické proměnné si tedy program pamatuje konkrétní pojmenovaný stav. To může na první pohled působit jako zbytečná komplikace, ale právě zde začíná filozofie stavových automatů. Když se později ke kódu vrátíme, nemusíme přemýšlet nad tím, co znamená hodnota true nebo false. Přímo vidíme, zda se zařízení nachází ve stavu rozsvícené nebo zhasnuté LED.

V hlavní smyčce je použita konstrukce switch, která podle aktuálního stavu vybere příslušnou část programu. Pokud se nacházíme ve stavu LED_OFF, LED dioda je zhasnutá a program testuje stisk tlačítka. Jakmile dojde ke stisku, neprovádí se žádné složité operace – jednoduše jen změní aktuální stav na LED_ON. Při dalším průchodu hlavní smyčkou díky tomu už program vstoupí do větve LED_ON. LED dioda se rozsvítí a program opět se testuje stisk tlačítka. Jakmile uživatel tlačítko stiskne, stav se změní zpět na LED_OFF.

Je zajímavé si uvědomit, že samotná změna stavu představuje veškerou logiku programu. Jednotlivé větve se starají pouze o to, co se má v daném stavu dít a za jakých okolností má dojít k přechodu do jiného stavu.

To je přesně základní myšlenka stavového automatu.

U takto jednoduchého příkladu může druhé řešení působit zbytečně rozvláčně. Jakmile však zařízení začne obsahovat více režimů činnosti, například inicializaci, běžný provoz, chybový stav, komunikaci po síti nebo čekání na uživatelský vstup, začnou být výhody pojmenovaných stavů velmi rychle patrné. Program se totiž přestává skládat z velkého množství podmínek a místo toho se mění v přehledný popis toho, v jakém stavu se zařízení nachází a kam se může přesunout dál.

Přidání dalšího stavu

Pokud jste nyní získali pocit, že stavový automat je pouze složitější způsob, jak rozsvítit LED diodu, nejste sami. U jednoduchých úloh se jeho výhody skutečně příliš neprojevují. Situace se ale začne měnit ve chvíli, kdy zařízení přechází mezi větším množstvím stavů. A právě na takovém příkladu si to nyní ukážeme.

Chci svůj současný program upravit tak, abych při prvním stisknutí LED rozsvítil, při dalším rozblikal a až při třetím stisknutí zhasl. Je to zkrátka taková ta klasická „blikačna“ na kolo.

Kdo chce, může si zkusit upravit první příklad, tj. ten napsaný klasicky. My se však zaměříme na FSM techniku.

Co tedy konkrétně musíme udělat? V první řadě je třeba rozšířit výčtový typ State o nový stav:

enum State {
    LED_OFF,
    LED_ON,
    LED_BLINK   // nový stav
};

Tím jsme dali našemu automatu najevo, že existuje ještě třetí stav, ve kterém se může nacházet. Žádná věda – prostě jsme přidali další položku do seznamu stavů. Pak musíme ještě udělat malou, ale důležitou změnu v přechodech. Dřív platilo, že po stisknutí tlačítka ve stavu LED_ON jsme se vraceli zpět do LED_OFF. To ale musíme teď změnit tak, že se místo toho přesuneme do nového stavu LED_BLINK a až z něj do stavu LED_OFF.

case LED_ON:
  digitalWrite(LED_PIN, HIGH);
  if (buttonState == LOW && lastButtonState == HIGH)
    state = LED_BLINK; // změna: nejde na OFF, ale na BLINK
break;

Tím z původního dvoustavového přepínače uděláme třístavový cyklus:

OFF → ON → BLINK → OFF

A tady se přesně už začíná objevovat to pravé kouzlo FSM – nechceme hromadu podmínek rozházených po celém kódu, ale jasně definované stavy a přechody mezi nimi.

Samotný stav LED_BLINK nyní doplníme jako další položky case ve switchi!

Pro řešení blikání LED lze již použít různé metody. Možná by někoho napadlo použít cyklus, který bude blikat LED dokud nedojde ke stisknutí tlačítka. My k tomu zde použijeme klasickou dvojici millis() + časová značka, abychom blikali „neblokujícím“ způsobem. Díky tomu program dál normálně reaguje na tlačítko a nezasekne se v nějakém stavu nebo se nezdržuje příkazy typu delay().

O blokujícím či neblokujícím způsobu řešení úloh se asi ještě zmíníme, ale nyní to není důležité pro ukázku techniky FSM. Berme to tedy tak, že zkrátka ve stavu LED_BLINK blikáme LED, dokud nedojde ke stisku tlačítka.

A každý programátor má zkrátka občas nějakou svou programátorskou „libůstku“. 😉

Blikací část kódu by tedy mohla vypadat kupříkladu takto:

case LED_BLINK:
     // neblokující blikání
     if (millis() - lastBlinkTime > 500) { // uplynul cas 500ms, zmen stav 
          lastBlinkTime = millis();        // uloz cas zmeny pro pristi test
          ledState = !ledState;            // zmen stav LED
          digitalWrite(LED_PIN, ledState); // zapis stav na GPIO
     }

     // test tlacitka
     if (buttonState == LOW && lastButtonState == HIGH)
          state = LED_OFF;   // další klik → OFF
break;

kde si ale musíme dodeklarovat proměnnou pro lastBlinkTime pro uložení času poslední změny stavu LED a proměnnou ledState, ve které je uložen aktuální stav LED (při blikání), aby mohlo dojít k jeho negaci.

// proměnné pro blikání
unsigned long lastBlinkTime = 0;
bool ledState = LOW;

Důležité je, že i v tomto stavu pořád hlídáme tlačítko stejně jako v těch předchozích (druhá podmínka). Jakmile přijde další stisk, přecházíme do stavu LED_OFF. Tím zůstává celé ovládání konzistentní – jeden stisk tlačítka, vždy jeden krok v našem stavovém automatu. Celkově jsme tedy nepřepisovali logiku programu, jen jsme ji rozšířili. Přidali jsme nový stav, upravili jeden přechod a nadefinovali jeho chování.

A přesně takhle se FSM používá v praxi – místo složitých podmínek prostě přidáme další stav a necháme automat, ať si to „odřídí“ za nás.

Kód programu – Blikačka na kolo

const uint8_t LED_PIN = 2;
const uint8_t BTN_PIN = 0;

enum State {
    LED_OFF,
    LED_ON,
    LED_BLINK   // nový stav
};

State state = LED_OFF;
bool lastButtonState = HIGH;

// proměnné pro blikání
unsigned long lastBlinkTime = 0;
bool ledState = LOW;

void setup() {
    pinMode(LED_PIN, OUTPUT);
    pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
    bool buttonState = digitalRead(BTN_PIN);

    switch (state) {

        case LED_OFF:
            digitalWrite(LED_PIN, LOW);
            if (buttonState == LOW && lastButtonState == HIGH)
                state = LED_ON;
        break;

        case LED_ON:
            digitalWrite(LED_PIN, HIGH);
            if (buttonState == LOW && lastButtonState == HIGH)
                state = LED_BLINK;   // změna: nejde na OFF, ale na BLINK
        break;

        case LED_BLINK:
            // neblokující blikání
            if (millis() - lastBlinkTime > 500) {
                lastBlinkTime = millis();
                ledState = !ledState;
                digitalWrite(LED_PIN, ledState);
            }

            if (buttonState == LOW && lastButtonState == HIGH)
                state = LED_OFF;   // další klik → OFF
        break;
    }

    lastButtonState = buttonState;
}

Tak co? Už tomu FSM přicházíme na chuť?

Druhý praktický příklad – Wi-Fi klient

Využijeme jednu z nejzajímavějších vlastností modulu ESP32, kterou je integrované Wi-Fi rozhraní. Naším cílem bude připojit se k bezdrátové síti, následně odeslat HTTP požadavek na vzdálený server, získat odpověď a výsledek vypsat do sériového monitoru.

Abychom tento úkol trochu konkretizovali, připojíme se k Wi-Fi síti a prostřednictvím internetového dotazu na adrese:
http://checkip.amazonaws.com
se zpětně dozvíme IP adresu, na které je náš modul ESP32 dostupný z internetu.

Na první pohled se nejedná o nic složitého. Toto jsme již řešili v článku: ESP32: HTTP a HTTPS požadavek.

Pokud bychom chtěli úlohu pouze rychle zprovoznit, pravděpodobně bychom vše zapsali do jedné funkce a postupně vykonávali jednotlivé kroky za sebou – podobně jako je popsáno ve výše uvedeném článku. Takové řešení funguje, ale z pohledu návrhu programu pro tento článek by příliš zajímavé nebylo.

Zkusme se na celý problém podívat jinak. Když si odmyslíme konkrétní příkazy a knihovny, zjistíme, že zařízení během své činnosti prochází několika jasně definovanými etapami. Nejprve se musí připojit k Wi-Fi síti. Jakmile je připojeno, může vytvořit HTTP požadavek. Poté musí počkat na odpověď serveru, odpověď zpracovat a nakonec celou operaci ukončit. Každý z těchto kroků představuje určitý stav programu a mezi jednotlivými stavy existují jasně definované přechody.

Právě zde začíná být stavový automat velmi přirozeným řešením. Místo toho, abychom přemýšleli nad jednotlivými příkazy, budeme uvažovat nad tím, v jaké fázi komunikace se právě nacházíme. Program si nebude pamatovat kvantum různých příznaků a pomocných proměnných. Bude si pamatovat pouze svůj aktuální stav. Z něj pak jednoznačně vyplývá, co má v daném okamžiku dělat a jaká událost jej posune dál.

Celou úlohu si můžeme představit jako následující jednoduchý diagram:

diagram FSM řešení
Obrázek č. 1 – Diagram FSM řešení programu

Po spuštění programu (START) se nacházíme ve stavu připojování k Wi-Fi síti (WIFI_CONNECT). Pokud je připojení úspěšné, přejdeme ke stavu odeslání HTTP požadavku (HTTP_REQUEST). Následuje stav zpracování odpovědi (READ_RESPONSE) a po jeho dokončení se dostaneme do koncového stavu (DONE). Pokud se v některém kroku objeví problém, můžeme přejít do chybového stavu (ERROR) a podle potřeby celou operaci ukončit nebo opakovat.

Je zajímavé si všimnout, že v tomto případě stavy již nepředstavují fyzický stav zařízení, jako tomu bylo u LED diody v předchozím příkladu. LED byla buď rozsvícená, nebo zhasnutá. Nyní ale stav reprezentuje určitou fázi probíhajícího procesu.

POZNÁMKA:
Ještě než se podíváme na samotnou implementaci, stojí za zmínku jedna zajímavá vlastnost dobře navrženého stavového automatu. Pokud dokážeme pojmenovat jeho stavy, často už z jejich názvů pochopíme, co program dělá. Výčet stavů tak funguje nejen jako součást zdrojového kódu, ale také jako určitá forma dokumentace. Když se po několika měsících vrátíme ke staršímu projektu a uvidíme stavy jako WIFI_CONNECT, HTTP_REQUEST, READ_RESPONSE, DONE nebo ERROR, velmi rychle si připomeneme základní logiku programu, aniž bychom museli podrobně studovat celý zdrojový kód.

Pro náš program si (zatím) definujeme následující základní stavy:

START
WIFI_CONNECT
HTTP_REQUEST
READ_RESPONSE
DONE
ERROR

které vycházejí z předchozího diagramu. V zápisu kódu programu by definice stavů mohla vypadat následujícím způsobem pomocí globálně definovaného výčtového typu:

enum State {
    START,
    WIFI_CONNECT,
    HTTP_REQUEST,
    READ_RESPONSE,
    DONE,
    ERROR
};

Dále si připravíme globální proměnnou state, která tohoto výčtového typu State a do které budeme ukládat aktuální stav programu.

State state;

V hlavní nekonečné smyčce loop() pak pomocí této stavové proměnné a jejího testování pomocí struktury budeme vytvářet jednotlivé „akce“ pro dané stavy.

switch (state) {
  case START:
    // akce pro stav START
  break;

  case WIFI_CONNECT:
    // akce pro stav WIFI_CONNECT
  break;

atd.

Tím získáme základní kostru našeho stavového automatu. Zatím ale obsahuje pouze názvy stavů a žádnou skutečnou funkčnost. Než si ukážeme kompletní program, bude užitečné podívat se především na jednotlivé stavy samostatně a vysvětlit si, jaký je jejich úkol.

Začněme tedy prvním stavem, kterým začíná připojení k bezdrátové síti.

Stav START

Úkolem tohoto výchozího stavu je spustit připojení modulu ESP32 k bezdrátové síti. Dokud není připojení navázáno, nemá smysl pokračovat v dalších krocích.

case START:
      WiFi.begin(ssid, password);
      Serial.println("Pripojuji k Wi-Fi.");

      state = WIFI_CONNECT;
break;

Stav WIFI_CONNECT

Úkolem části WIFI_CONNECT je ověřit stav Wi-Fi rozhraní. Pokud se zařízení teprve připojuje, vypisují se tečky, teprve po úspěšném připojení se přejde do dalšího stavu.

case WIFI_CONNECT:
      if (WiFi.status() == WL_CONNECTED) {
          Serial.println();
          Serial.println("Wi-Fi pripojena");

          state = HTTP_REQUEST;

      } else {
          Serial.print(".");
          delay(500);
      }
break;

Jak vidíme, tato část kódu je vlastně pouhá podmínka. Připojení k Wi-Fi bylo zahájeno v části START. Zároveň někoho jistě napadá, že tato část není zrovna ideálně napsána, protože zde může docela dobře dojít k zamrznutí programu – pokud by se modul ESP32 nedokázal připojit (např. zadaná Wi-Fi síť nebude existovat), nebyl by se schopen překlopit do dalšího stavu ani vyvolat chybu. Tato část by si jistě zasloužila nějaký timeout. A to je opět jedna z výhod techniky FSM. Pokud něco takového objevíme, můžeme změnit kód jen pro daný stav a máme jistotu, že tím zbytek programu nezasáhneme.

Takže… co třeba takto:

int wifiRetryCounter = 0;
const int WIFI_MAX_RETRIES = 5;

case WIFI_CONNECT:
      if (WiFi.status() == WL_CONNECTED) {
          Serial.println();
          Serial.println("Wi-Fi pripojena");
          wifiRetryCounter = 0;

          state = HTTP_REQUEST;

      } else {
          Serial.print(".");
          delay(500);

          wifiRetryCounter++;

          if (wifiRetryCounter > WIFI_MAX_RETRIES) {

              state = ERROR;
          }
break;

Všimněme si, že tento blok neřeší vůbec nic jiného než připojení k síti. Nezajímá ho HTTP komunikace ani zpracování odpovědi serveru. Jeho jediným úkolem je úspěšně dokončit připojení nebo při překročení předem stanovených počtů připojení (konstanta WIFI_MAX_RETRIES) vyvolat chybu. Následně předat řízení dalšímu ze stavů, které nastaly (HTTP_REQUEST nebo ERROR).

Stav HTTP_REQUEST

Jakmile máme navázané síťové spojení, můžeme vytvořit HTTP požadavek. V našem případě se připojíme k serveru a odešleme jednoduchý požadavek typu GET.

case HTTP_REQUEST:
      Serial.println("Odesilam HTTP pozadavek...");

      if (client.connect(host, 80)) {
          client.println("GET / HTTP/1.1");
          client.println("Host: checkip.amazonaws.com");
          client.println("Connection: close");
          client.println();

          state = READ_RESPONSE;

      } else {

          state = ERROR;
      }
break;

Opět je dobře vidět základní filozofie FSM. Tento stav neřeší navazování Wi-Fi spojení ani zpracování přijatých dat. Jeho jedinou odpovědností je odeslat požadavek serveru. Pokud se operace podaří, přejde program do dalšího stavu. Pokud ne, přechází do chybového stavu.

Stav READ_RESPONSE

Po odeslání požadavku přichází na řadu zpracování odpovědi serveru. V našem demonstračním příkladu budeme obsah odpovědi jednoduše vypisovat do sériového monitoru.

case READ_RESPONSE:
      while (client.available()) {
          Serial.write(client.read());
      }

      if (!client.connected()) {
          state = DONE;
      }
break;

V tomto stavu načítáme data přijatá ze serveru a vypisujeme jej na sériový port. Jakmile server ukončí spojení a všechna data jsou přečtena, můžeme pokračovat do koncového stavu.

Přesto se u této části trochu pozastavíme. Výše uvedený kód je sice funkční, ale moc chytře řešený není. Proč? Protože blokuje celý program! Pokud bude vzdálený server vracet velké množství dat, program se zde zasekne a bude ve smyčce while{} číst a číst, dokud bude co.

To není optimální. Další z vlastností stavového automatu na mikrokontroléru je, že by se měl program docela rychle vracet do hlavní smyčky. Proč? Protože tam zpravidla číhá tzv. watchdog, který hlídá běh celého programu.

VYSVĚTLENÍ:
Watchdog (někdy otrocky překládán do češtiny jako „hlídací pes“) je speciální hardwarový mechanismus, který je součástí většiny mikrokontrolérů. Jeho úkolem je hlídat, zda program běží tak, jak má. V principu funguje jako časovač, který neustále odpočítává určitý interval. Program mu musí v pravidelných chvílích „dát vědět“, že je stále v pořádku – typicky jeho vynulováním. Pokud to ale program z nějakého důvodu neudělá (například proto, že se zasekl, uvízl v nekonečné smyčce nebo čeká na něco, co nepřijde), watchdog vyhodnotí situaci jako chybu a provede restart celého mikrokontroléru.

V našem příkladu sice žádný watchdog nemáme, ale to neznamená, že bychom si program pro něj nemohli trochu připravit. Takže zkusíme část kódu uvedenou výše přepsat aspoň trochu „neblokujícím“ způsobem.

case READ_RESPONSE:
      if (client.available()) {
          Serial.write(client.read());
      } else if (!client.connected()) {
                state = DONE;
             }
break;

Drobná změna cyklu while na podmínku if a její úprava zapříčinila, že se načte a vypíše jen jeden znak a program se vrací do hlavní smyčky, ale protože se nezměnil stav, po dokončení smyčky loop() spadne program opět do této části kódu a vypíše se další znak. A to celé se opakuje stále, dokud je co číst. Pak se v případě odpojení pokračuje do stavu DONE. Pochopitelně by jistě stálo za to, ještě toto čtení kupříkladu omezit nějakou časovou konstantou, která by spojení po nějaké přehnaně dlouhé odezvě ukončila – třeba se k tomu ještě vrátíme.

Stav DONE

Tento stav představuje úspěšné dokončení celé operace.

case DONE:
      Serial.println();
      Serial.println("Hotovo.");
      delay(5000);

      state = WIFI_CONNECT;
break;

V našem jednoduchém příkladu zde pouze vypíšeme informaci do sériového monitoru. Počkáme 5 sekund a znova se pokusíme připojit. Jelikož bylo předtím připojení úspěšné, lze tedy předpokládat, že jsme stále připojeni na zvolenou Wi-Fi. Nevoláme tedy stav START, kde se aktivuje připojení WiFi.begin, ale stav WIFI_CONNECT, kde se (pro jistotu) ověří připojení a pokračuje se dál.

Ve skutečné aplikaci bychom zde mohli například zahájit další operaci, uložit data do paměti nebo jako zde po určité době celý proces zopakovat.

Stav ERROR

Každý robustnější program by měl počítat i s možností chyby. Proto jsme do návrhu zařadili samostatný chybový stav.

case ERROR:
      Serial.println("Doslo k chybe.");
      delay(5000);
      WiFi.disconnect(true);

      state = START;
break;

Výhodou samostatného chybového stavu je skutečnost, že veškeré zpracování chyb máme soustředěné na jednom místě.

Jelikož zde nevíme, co bylo příčinou chyby, po krátké pauze vypneme Wi-Fi a vyvoláme stav START.

V reálné aplikaci by bylo lepší zvolit několik různých chybových stavů. Pokud se později rozhodneme implementovat opakování spojení, restart zařízení nebo podrobnější diagnostiku, budeme pak přesně vědět, kam příslušný kód umístit.

Když se nyní podíváme na jednotlivé stavy vedle sebe, začíná být opět dobře patrná hlavní výhoda stavového automatu. Každý stav řeší právě jednu konkrétní část problému a nic navíc. Program je rozdělen na malé, logicky oddělené bloky, jejichž chování lze pochopit i bez znalosti celého projektu.

Teď je asi na čase všechny tyto dílčí části spojit do jednoho funkčního programu.

Spojení do jednoho funkčního programu

Když máme jednotlivé stavy rozebrané samostatně, nastává ten nejdůležitější krok – spojit vše dohromady do jednoho funkčního celku. Nic složitého nepřidáváme, žádné speciální architektury – pouze doplňujeme podporu logiky, kterou jsme si postupně připravili.

Pro funkčnost na naší konkrétní Wi-Fi síti musíme v níže uvedeném programu do konstant ssid a password doplnit název a heslo konkrétní Wi-Fi sítě, ke které chceme modul ESP32 připojit.


#include 

const char* ssid = "YOUR_SSID";
const char* password = " YOUR_PASSWORD ";

const char* host = "checkip.amazonaws.com";

int wifiRetryCounter = 0;
const int WIFI_MAX_RETRIES = 5;

WiFiClient client;

enum State {
    START,
    WIFI_CONNECT,
    HTTP_REQUEST,
    READ_RESPONSE,
    DONE,
    ERROR
};

State state;

void setup() {
    Serial.begin(115200);
    state = START;
}

void loop() {
    switch (state) {

        case START:
            WiFi.begin(ssid, password);
            Serial.println("Pripojuji k Wi-Fi.");
            state = WIFI_CONNECT;
        break;

        case WIFI_CONNECT:
            if (WiFi.status() == WL_CONNECTED) {
                Serial.println();
                Serial.println("Wi-Fi pripojena");
                wifiRetryCounter = 0;
                state = HTTP_REQUEST;
            } else {
                Serial.print(".");
                delay(500);
                wifiRetryCounter++;
                if (wifiRetryCounter > WIFI_MAX_RETRIES) {
                    state = ERROR;
                }
            }
        break;

        case HTTP_REQUEST:
            Serial.println("Odesilam HTTP pozadavek...");
            if (client.connect(host, 80)) {
                client.println("GET / HTTP/1.1");
                client.println("Host: checkip.amazonaws.com");
                client.println("Connection: close");
                client.println();
                state = READ_RESPONSE;
            } else {
                state = ERROR;
            }
        break;

        case READ_RESPONSE:
            if (client.available()) {
                Serial.write(client.read());
            } else if (!client.connected()) {
                        state = DONE;
                   }
        break;

        case DONE:
            Serial.println();
            Serial.println("Hotovo.");
            delay(5000);
            state = WIFI_CONNECT;
        break;

        case ERROR:
            Serial.println("Doslo k chybe.");
            delay(5000);
            WiFi.disconnect(true);
            state = START;
        break;
    }
}

Na první pohled může program působit o něco delší než jednoduchá sekvence příkazů, která by celý problém řešila „postaru“. To je ale u stavových automatů naprosto normální a ve skutečnosti to není nevýhoda, ale spíše vedlejší efekt toho, že každý krok je jasně oddělený a pojmenovaný.

Hlavní smyčka loop() se tak změnila v jednoduchý řídicí mechanismus, který pouze opakovaně vyhodnocuje aktuální stav a podle něj vykonává příslušnou část programu. V praxi to znamená, že se vyhýbáme složitým kombinacím podmínek a pomocných proměnných. Místo toho máme jednu jedinou informaci, která určuje chování celého systému – aktuální stav.

Co by se stalo, kdybychom program začali rozšiřovat?

U příkladu jsme skončili ve chvíli, kdy jsme měli funkční stavový automat pro připojení k Wi-Fi a jednoduchý HTTP dotaz, který nám vrátil naši internetovou IP adresu. Program je přehledný, jednotlivé kroky jsou oddělené do stavů a na první pohled je jasné, co se v každé části děje.

Teď si ale zkusme položit mnohem důležitější otázku: Co se stane, když tenhle jednoduchý program začneme používat v reálném světě a postupně ho budeme rozšiřovat?

Protože přesně to se v praxi děje vždycky. Nikdo většinou nepíše firmware tak, že by od začátku znal všechny požadavky. Začíná se jednoduchou funkcí, která se postupně rozrůstá o další a další vlastnosti.

Co když se Wi-Fi nepřipojí hned?

První velmi častý problém je samotné připojení k Wi-Fi. V ideálním světě se zařízení připojí okamžitě. V reálném světě to ale může trvat několik sekund, nebo se to nepovede vůbec.

Najednou se objevují otázky:

  • kolikrát to máme zkusit?
  • jak dlouho čekat mezi pokusy?
  • co dělat, když se nepřipojíme ani po několika pokusech?

V „klasickém“ přístupu by to často skončilo přidáním dalších proměnných typu retryCounter, connectionTimeout, wifiFailed a několika vnořených podmínek if. Postupně by se z jednoduché logiky stal poměrně křehký systém, kde není úplně jasné, která podmínka kdy platí.

My už jsme tuto otázku tak trochu řešili, když jsme si přidali počítadlo pokusů připojení. Pochopitelně stejně tak můžeme ale přidat i stav řešící nastalý problém – my jsme nyní volali obecnou chybu stav ERROR – ale nyní je situace mnohem konkrétnější. Jednoduše si řekneme, že přidáme nový stav, například: WIFI_ERROR. A přesně definujeme, kdy do něj přecházíme.

Tím se dostáváme k tomu, jak by takové rozšíření mohlo vypadat přímo v kódu. Nejde o kompletní program, ale o ukázku toho, jak snadno lze FSM rozšířit o nové chování, aniž bychom museli přepisovat existující logiku.

enum State {
    WIFI_CONNECT,
    HTTP_REQUEST,
    READ_RESPONSE,
    DONE,
    ERROR,
    WIFI_ERROR    // nový stav
};

Samotná logika připojování k Wi-Fi se pak zůstává skoro stejná, přibyde jen nový stav řešící chybu připojení:

case WIFI_CONNECT:
      if (WiFi.status() == WL_CONNECTED) {
          Serial.println();
          Serial.println("Wi-Fi pripojena");
          wifiRetryCounter = 0;

          state = HTTP_REQUEST;
      } else {
          Serial.print(".");
          delay(500);
          wifiRetryCounter++;

          if (wifiRetryCounter > WIFI_MAX_RETRIES) {

              state = WIFI_ERROR;     // chyba pripojeni, volej nový stav
          }
      }
break;

case WIFI_ERROR:    // co delat pri novem stavu
       Serial.println("Wi-Fi se nepodarilo pripojit.");
       Serial.println("Novy pokus o start");
       WiFi.disconnect(true);
 
      state = START;
break;

Na tomto místě je dobře vidět, že jsme skoro nemuseli přepisovat žádnou existující část programu. Pouze jsme přidali nový stav a definovali jeho chování. Rozšiřování programu neznamená zbytečné komplikování existující logiky, ale spíše její rozdělení do dalších, stále dobře čitelných částí. Místo přidávání dalších příznaků a podmínek jednoduše přidáváme nové stavy, které se zapojují do již existující struktury.

Tento přístup se začne výrazně vyplácet ve chvíli, kdy podobným způsobem budeme muset rozšířit i HTTP část programu – například o timeout, opakování požadavku nebo zpracování neúplné odpovědi.

Co když HTTP odpověď nepřijde?

A přesně stejný problém se začne projevovat i v další části našeho programu – tedy při samotné HTTP komunikaci.

Doposud jsme předpokládali ideální scénář: server odpoví rychle, data dorazí vcelku a spojení se korektně ukončí. V reálném světě ale síťová komunikace zdaleka takto spolehlivá není. Server nemusí odpovědět vůbec, odpověď může přijít se zpožděním, nebo může být přerušena uprostřed přenosu.

Opět se tak přirozeně objevují otázky, které musíme nějak řešit:

  • jak dlouho čekáme na odpověď?
  • co když dorazí jen část dat?
  • co když se spojení během čtení přeruší?

Bez stavového automatu by se tato logika velmi rychle začala „rozlévat“ do různých částí programu. Přidávaly by se další podmínky uvnitř smyček, kontrolní proměnné, hlídání časů a různé výjimky. A velmi brzy bychom ztratili přehled o tom, co se vlastně v jakém okamžiku děje.

Místo toho, abychom přidávali složitou logiku do existujících bloků, jednoduše doplníme nové stavy, které přesně popisují jednotlivé situace. V našem případě to mohou být například:

  • HTTP_TIMEOUT
  • HTTP_ERROR

Tím se z jednoho obecného stavu READ_RESPONSE stává řízený proces, který má jasně definované hranice a jasně pojmenované výjimky.

Ukázkově si můžeme představit, že rozšíříme část programu odpovědnou za čtení dat následovně:

unsigned long httpStartTime = 0;
const unsigned long HTTP_TIMEOUT_MS = 5000;

Samotný stav pro čtení odpovědi pak může být rozšířen o časový dohled:

case READ_RESPONSE:
      if (httpStartTime == 0) {
            httpStartTime = millis();
      }

      if (client.available()) {
           Serial.write(client.read());
      } else if (!client.connected()) {
              httpStartTime = 0;

              state = DONE;
             }

      // timeout ochrana
      if (millis() - httpStartTime > HTTP_TIMEOUT_MS) {
            Serial.println("HTTP timeout");
            httpStartTime = 0;

            state = HTTP_TIMEOUT;
      }
break;

case HTTP_TIMEOUT:
    Serial.println("Cekani na odpoved vypršelo.");

    state = HTTP_ERROR;
break;

case HTTP_ERROR:
    Serial.println("Chyba pri HTTP komunikaci.");

    state = ERROR;
break;

Tímto způsobem jsme opět nerozšiřovali složitost uvnitř jednoho velkého bloku, ale rozdělili jsme nové situace do samostatných stavů. Každý z nich řeší přesně jednu konkrétní věc – timeout, chybu nebo úspěšné dokončení.

Pochopitelně všechny „nové“ stavy musíme přidat do hlavního výčtu stavů:

enum State {
    WIFI_CONNECT,
    HTTP_REQUEST,
    READ_RESPONSE,
    DONE,
    ERROR,
    WIFI_ERROR,
    HTTP_ERROR,
    HTTP_TIMEOUT
};

Na obrázku č. 2 je vidět běžící program včetně výstupu na sériovém monitoru prostředí Arduino IDE, ve kterém máme vyznačené jednotlivé stavy programu. Díky tomu je dobře patrné, jak jednotlivé stavy postupně procházejí a jak se celý proces komunikace reálně chová při běhu na ESP32.

prubeh pripojeni
Obrázek č. 2 – Výstup na sériovém monitoru a vyznačené jednotlivé stavy programu

Jak by to vypadalo bez FSM?

Pokud si na chvíli představíme, že bychom stejnou funkcionalitu implementovali bez stavového automatu, museli bychom současně sledovat několik různých informací:

  • zda jsme připojeni k Wi-Fi
  • kolikrát jsme se pokusili navázat spojení
  • zda jsme již odeslali HTTP požadavek
  • zda už přišla odpověď
  • zda nevypršel časový limit

Každá z těchto informací by byla reprezentována samostatnou proměnnou a výsledná logika by byla tvořena kombinacemi podmínek, které se navzájem ovlivňují. Čím více by se program rozšiřoval, tím složitější by bylo pochopit, v jakém přesně stavu se systém nachází.

Právě zde se ukazuje zásadní rozdíl. Ve stavovém automatu není potřeba skládat složité kombinace podmínek – stačí se podívat na jednu jedinou informaci: aktuální stav systému.

Závěr – Kam jsme se vlastně dostali

Když se ohlédneme zpět, začali jsme na úplně jednoduchém problému – rozsvícení LED diody pomocí tlačítka. Tam se mohlo zdát, že stavový automat je spíš elegantní cvičení než něco opravdu potřebného. Jenže jakmile jsme přidali Wi-Fi připojení, HTTP komunikaci a začali řešit reálné situace jako výpadky spojení, zpoždění odpovědí nebo chyby v komunikaci, začal se stejný přístup ukazovat v úplně jiném světle.

Najednou už nejde o to, jak napsat pár podmínek do loop(), ale jak vůbec uchopit chování celého zařízení tak, aby se v něm bylo možné vyznat i za měsíc, nebo když ho po nás bude upravovat někdo úplně jiný. A právě v tom je hlavní síla stavových automatů – neřeší jen to, aby program fungoval, ale aby bylo jasné, proč funguje právě takhle.

Ve všech příkladech jsme vlastně dělali totéž: rozdělovali chování zařízení do jasně pojmenovaných stavů a definovali, co se v každém z nich děje a kdy se přechází dál. A čím složitější scénář byl, tím víc se ukazovalo, že tento způsob uvažování není jen „hezký“, ale hlavně praktický.

Závěr? Teď to teprve začíná!

Co jsme si ukázali, je jen základní kostra. V praxi se stavové automaty často rozrůstají do mnohem zajímavějších struktur – přibývají podstavy, paralelní větve, časové podmínky nebo reakce na více událostí současně. Z jednoduchého diagramu se tak může stát poměrně bohatý systém, který už připomíná spíš malý ekosystém než lineární program.

A právě tady se otevírá ta „divočejší část“ celé problematiky. Jakmile si člověk jednou zvykne přemýšlet v stavech, začne je vidět všude – v komunikaci po síti, v řízení motorů, v uživatelských rozhraních nebo třeba v protokolech, které na první pohled vypadají jako obyčejné sekvence příkazů.

Stavový automat tak není konečný cíl. Je to spíš vstupní brána do způsobu uvažování, který pomáhá udržet složitost pod kontrolou. A čím víc se do něj člověk ponoří, tím víc zjišťuje, že tahle „jednoduchá myšlenka se stavy“ je ve skutečnosti jeden z nejpraktičtějších nástrojů v celém světě embedded vývoje.

Pošli signál autorovi: