ESP32 Intelligens Wi-Fi roaming és MQTT kommunikációs alapprogram

A program áttekintése

A bemutatott program nem egy végleges, önálló alkalmazás, hanem egy stabil és továbbfejleszthető alapprogram. Biztosítja a vezeték nélküli hálózati kommunikációhoz, a távoli adatkapcsolathoz és a hozzáférési pontok közötti automatikus váltáshoz szükséges alapvető szolgáltatásokat. Erre a programstruktúrára később különböző célalkalmazások építhetők további funkciók hozzáadásával.

A fejlesztés célja egy olyan ESP32-alapú Wi-Fi-kliens megvalósítása, amely képes a háttérben, a főprogram futásának blokkolása nélkül feltérképezni az elérhető vezeték nélküli hálózatokat, majd ugyanazon SSID-hez tartozó hozzáférési pontok közül kiválasztani a kedvezőbb kapcsolatot biztosító AP-t. Ennek köszönhetően az eszköz mozgás közben is stabilabb és megbízhatóbb hálózati kapcsolatot tarthat fenn.

ESP32 Intelligens Wi-Fi roaming és MQTT kommunikációs alapprogram

A program kezeli az ESP32 Wi-Fi-kapcsolatát, folyamatosan felügyeli annak állapotát, valamint megvalósítja az MQTT-alapú kommunikációt. Tartalmazza továbbá a hozzáférési pontok közötti automatikus váltás, vagyis a roaming alaplogikáját. A döntési folyamat során figyelembe veszi a mért jelerősséget, annak átlagolt értékét, a beállított hiszterézist, valamint az új hozzáférési pont stabilitását.

A fejlesztés során kiemelt szempont volt a megbízható, áttekinthető és könnyen bővíthető programstruktúra kialakítása. Ennek megfelelően a Wi-Fi-kapcsolat kezelése, a hálózatkeresés, a roaming, az MQTT-kommunikáció és az időszinkronizálás egymástól elkülönített függvényekben valósul meg. Ez a moduláris felépítés megkönnyíti a hibakeresést és a továbbfejlesztést, valamint lehetővé teszi, hogy a későbbi alkalmazások minimális módosítással használják fel a már elkészített alapfunkciókat.

A program időzítése elsősorban a millis() függvény által visszaadott futási időre épül. Ez lehetővé teszi a blokkoló várakozások elkerülését, így a rendszer folyamatosan képes felügyelni a hálózati kapcsolatokat és gyorsan reagálni az eseményekre.

A program részletes bemutatása

Felhasznált könyvtárak

A program működéséhez három könyvtár szükséges.

A WiFi.h könyvtár biztosítja az ESP32 vezeték nélküli hálózati funkcióit, például a csatlakozást, a hálózatok keresését és az RSSI értékek lekérdezését.

A PubSubClient.h könyvtár az MQTT kommunikáció megvalósításáért felel. Ennek segítségével az eszköz üzeneteket tud küldeni és fogadni egy MQTT brokeren keresztül.

A time.h könyvtár az internetes időszinkronizációt teszi lehetővé, amelyet a program az indulási idő meghatározására használ.

#include <WiFi.h>
#include <PubSubClient.h>
#include "time.h"

Konfiguráció és globális változók

A program elején találhatók azok a konstansok és globális változók, amelyek a hálózati kommunikációhoz és a program működéséhez szükséges alapvető beállításokat tartalmazzák. Itt kerül megadásra a Wi-Fi hálózat neve (SSID) és jelszava, az MQTT broker IP-címe és portszáma, valamint azok az MQTT témák (topikok), amelyeken keresztül az ESP32 kommunikál.

A program használatához ezeket az értékeket a saját hálózati környezetnek megfelelően kell beállítani. Ennek megfelelően meg kell adni a használni kívánt Wi-Fi hálózat csatlakozási adatait, valamint az MQTT broker IP-címét és a kommunikációhoz használt portszámot.

// Wi-Fi csatlakozási adatok
const char* ssid = "mySSID";
const char* password = "myPassword";

// MQTT szerver beállításai
const char* mqttServer = "192.168.1.2";
const uint16_t mqttPort = 1883;

Ezt követően létrejön egy Wi-Fi kliens és egy MQTT kliens objektum. Ezek felelnek a hálózati kapcsolat, illetve az MQTT kommunikáció kezeléséért.

A globális változók között találhatók továbbá az RSSI átlagolásához szükséges tömbök, a roaming döntési folyamatát vezérlő állapotjelzők, valamint az időzítések és az aktuális kapcsolat állapotát tároló jelzők.

A program működése néhány konstans módosításával egyszerűen testre szabható.

A wifiScanFreq konstans határozza meg, hogy milyen időközönként induljon új Wi-Fi hálózatkeresés. Az érték ezredmásodpercben van megadva.

// Hálózatkeresés periódusa ezredmásodpercben megadva
unsigned long wifiScanFreq = 15000;  // 15 másodperc

Az RSSI átlagolásához használt minták száma. Az rssiSampleCount konstans adja meg, hogy a program hány egymást követő RSSI mérésből számítson átlagot. Nagyobb érték stabilabb átlagot eredményez, ugyanakkor lassabban követi a jelerősség változását.

// Ennyi mintából számol átlagot.
const int rssiSampleCount = 3;

A requiredStableScans konstans azt határozza meg, hogy az új hozzáférési pontnak hány egymást követő hálózatkeresés során kell a legjobb választásnak bizonyulnia, mielőtt a program végrehajtja a roamingot.

// Az új AP-nak legalább ennyi egymást követő keresés során 
// is a legjobbnak kell lennie!
const int requiredStableScans = 2;

A rssiHysteresis konstans adja meg azt a minimális jelerősség-különbséget, amely szükséges ahhoz, hogy a program új hozzáférési pontra váltson. Minél kisebb ez az érték, annál érzékenyebben reagál a rendszer a jelerősség változására, míg nagyobb érték esetén stabilabban ragaszkodik a meglévő kapcsolathoz.

// Az új AP csak akkor választható, ha legalább
// ennyi dBm-mel jobb a jelenlegi kapcsolatnál
const int rssiHysteresis = 12;          // 12 dBm

Eseményvezérelt Wi-Fi Kezelés

A program eseményvezérelt módon figyeli a Wi-Fi-kapcsolat állapotát. Az egyes hálózati eseményeket a WiFiEvent() callback függvény kezeli, amelyet az ESP32 Wi-Fi könyvtára automatikusan meghív, amikor változás történik a hálózati kapcsolat állapotában.

void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info)
{
  switch(event)
  {
    // Sikeres csatlakozás a hozzáférési ponthoz
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      ....
      break;

    // Az eszköz IP-címet kapott, a kapcsolat használatra kész
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      ....
      break;

    // Kapcsolat megszakadt, az állapotjelzők alaphelyzetbe állítása  
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      ...
      break;

    // Egyéb Wi-Fi események kezelése nem szükséges
    default:
      break;
  }
}

Amikor az ESP32 sikeresen csatlakozik egy hozzáférési ponthoz, a program ezt jelzi a soros monitoron. Ebben az állapotban a vezeték nélküli kapcsolat már létrejött, az eszköz azonban még az IP-cím kiosztására vár.

// Sikeres csatlakozás a hozzáférési ponthoz
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      if(debug)
      {
        Serial.println();
        Serial.print("Connected to the ");
        Serial.println(WiFi.SSID().c_str());
        Serial.println(" Waiting for IP address...");
      }
      break;

Miután IP-címet kap a hálózattól, beállít egy kapcsolati állapotjelzőt, eltárolja az aktuális BSSID-t, valamint kiírja az IP-címet, az aktuális RSSI értéket és az eszköz nevét.

Az eltárolt BSSID-re a későbbi hálózatkeresések során van szükség, mivel ennek segítségével a program meg tudja különböztetni az aktuálisan használt hozzáférési pontot a többi, azonos SSID-vel rendelkező AP-tól.

// Az eszköz IP-címet kapott, a kapcsolat használatra kész
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      wifiConnected = true;   // kapcsolati állapotjelző beállítása
      currentBssid = WiFi.BSSIDstr();
      if(debug)
      {
        Serial.println();
        Serial.println("IP address received!");
        Serial.print("IP: ");
        Serial.println(WiFi.localIP());
        Serial.print("BSSID: ");
        Serial.println(currentBssid);
        Serial.print("HostName: ");
        Serial.println(WiFi.getHostname());
        Serial.print("RSSI: ");
        Serial.println(WiFi.RSSI());
        Serial.println();
      }
      break;

Ha a Wi-Fi-kapcsolat megszakad, a program törli a kapcsolatot jelző állapotváltozót. Emellett alaphelyzetbe állítja a hálózatkeresés állapotát is, hogy a következő csatlakozás után új keresés indulhasson.

// Kapcsolat megszakadt, az állapotjelzők alaphelyzetbe állítása  
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      wifiConnected = false;
      isScanning = false;
      break;

Kapcsolódás a hálózathoz

A setupWifi() függvény valójában nemcsak inicializálja a Wi-Fi-t, hanem gondoskodik arról is, hogy az eszköz tiszta állapotból induljon. Bontja az esetleges korábbi kapcsolatot, kliens (STA) módba állítja az ESP32-t, alaphelyzetbe állítja a hálózatkeresés állapotát, majd megkísérli a csatlakozást a megadott vezeték nélküli hálózathoz.

void setupWifi()
{
  // Korábbi Wi-Fi kapcsolat törlése
  WiFi.disconnect(true);

  // Az ESP32 kliens (Station) üzemmódba állítása
  WiFi.mode(WIFI_STA);

  // Hálózatkeresés állapotának alaphelyzetbe állítása
  isScanning = false;

  // Csatlakozási kísérlet a megadott Wi-Fi hálózathoz
  if(!connectToWifi(ssid, password, 0, NULL))
  {
    ...
  }
}

A tényleges kapcsolódást a connectToWifi() függvény valósítja meg. A függvény kialakítása lehetővé teszi, hogy normál csatlakozás és roaming során is ugyanaz a programrész kezelje a kapcsolat felépítését.

Kezdeti csatlakozáskor elegendő a Wi-Fi hálózat neve (SSID) és a hozzá tartozó jelszó megadása, ilyenkor az ESP32 automatikusan kiválasztja a megfelelő hozzáférési pontot.

Wi-Fi szkennelés után, ha a feltételek teljesülnek a roaming során azonban a program már ismeri a kiválasztott hozzáférési pont BSSID-jét és működési csatornáját, ezért közvetlenül ehhez az AP-hoz csatlakozik. Ez lerövidíti a csatlakozási folyamatot, mivel nincs szükség újabb hálózatkeresésre.

A csatlakozás külön függvénybe szervezésének előnye, hogy ugyanaz a megvalósítás használható mind a kezdeti hálózati kapcsolódás, mind a roaming során végrehajtott AP-váltás esetén, így elkerülhető a kódismétlés.

bool connectToWifi(const char* targetSsid, const char* targetPass, int32_t channel, const uint8_t* bssid)
{
  ....
  if(bssid != NULL)  // Ha ismert a cél AP BSSID-je, akkor közvetlenül ahhoz csatlakozunk
  {
    WiFi.begin(targetSsid, targetPass, channel, bssid, true);
  }
  else  // Egyébként hagyjuk, hogy az ESP32 válasszon hozzáférési pontot
  {
    WiFi.begin(targetSsid, targetPass);
  }
  ....

Sikeres csatlakozás után a függvény inicializálja a roaming működéséhez szükséges változókat. Újraindítja az RSSI átlagolását, törli az esetleges korábbi roaming jelöltet, valamint alaphelyzetbe állítja a roaming állapotát. Ennek köszönhetően minden új kapcsolat tiszta állapotból indul, függetlenül attól, hogy kezdeti csatlakozásról vagy hozzáférési pontok közötti váltásról van szó.


  ....
  // Sikertelen kapcsolódás esetén hamis értékkel tér vissza
  if(!wifiConnected) 
  {
    return false;
  }

  // Sikeres kapcsolódás után újraindítjuk az RSSI átlagolását
  averagedRssi = -100;
  updateAveragedRssi();

  // Töröljük az előző roaming döntéshez tartozó adatokat
  candidateBssid = "";
  candidateCount = 0;

  // Jelezzük, hogy a roaming művelet befejeződött
  isRoaming = false;

  return true;
}

Nem-blokkoló (Aszinkron) Wi-Fi Szkennelés

A hagyományos Wi-Fi szkennelési metódusok másodpercekre megállítják a program futását, ami egy IoT eszköznél adatvesztést okozhat. Az aszinkron Wi-Fi keresés lényege, hogy az ESP32 a háttérben keresi a környező hálózatokat és nem várja meg tétlenül a mérések végét. A háttérben folyó pásztázás alatt a főprogram folyamatosan, megszakítás nélkül futhat tovább.

A startAsynchronousScan() függvény elindítja ezt a keresést, de csak akkor, ha az eszköz már csatlakozott a hálózathoz, és éppen nincs folyamatban másik keresés.

void startAsynchronousScan()
{
  // Ha már fut keresés, vagy nincs aktív Wi-Fi kapcsolat,
  // akkor nincs szükség új keresés indítására
  if(isScanning || !wifiConnected) return;

  // Az aktuális kapcsolat jelerősségének frissítése
  // a roaming döntés előkészítéséhez
  updateAveragedRssi();

  // Keresés állapotjelző beállítása
  isScanning = true;

  if(debug) Serial.println("Network scan started...");

  // Aszinkron hálózatkeresés indítása
  // A program futása a keresés ideje alatt nem áll meg
  WiFi.scanNetworks(true, true, false);
}

Intelligens Roaming Algoritmus (Hiszterézissel)

A Wi-Fi jelerősség (RSSI) mérése a valóságban nagyon instabil. A falak, a tereptárgyak, de még az emberi test mozgása is pillanatnyi kilengéseket okozhat.

Az updateAveragedRssi() függvény RSSI mozgóátlag (Moving Average) szűrést végez. A rendszer nem egyetlen RSSI-mérés alapján hoz döntést, hanem az utolsó három mérési eredmény átlagát használja. Ez csökkenti a rövid idejű jelerősség-ingadozások hatását, így stabilabb roaming döntés hozható.

void updateAveragedRssi()
{
  // Aktuális Wi-Fi jelerősség lekérdezése
  int8_t currentRssi = WiFi.RSSI();

  // Első mérés esetén minden mintát az aktuális értékkel töltünk fel,
  // így az átlag azonnal használható értéket ad
  if(averagedRssi == -100)
  {
    for(int i = 0; i < rssiSampleCount; i++)
    {
      rssiSamples[i] = currentRssi;
    }
    averagedRssi = currentRssi;
    return;
  }

  // Az új mérési eredmény eltárolása a körkörös mintatárolóban
  rssiSamples[rssiSampleIndex] = currentRssi;

  // Következő minta helyének meghatározása
  rssiSampleIndex = (rssiSampleIndex + 1) % rssiSampleCount;

  // A tárolt RSSI értékek összegzése
  int32_t sum = 0;
  for(int i = 0; i < rssiSampleCount; i++)
  {
    sum += rssiSamples[i];
  }

  // Átlagos RSSI érték kiszámítása
  averagedRssi = sum / rssiSampleCount;
}

A hiszterézis egy olyan biztonsági sáv, amely megakadályozza, hogy az ESP32 folyamatosan oda-vissza ugráljon két Wi-Fi hozzáférési pont (AP) között, ha azok jelerőssége közel azonos (ezt hívják ping-pong effektusnak). Jelenleg a programban alkalmazott hiszterézis értéke 12 dBm.

Mi történne hiszterézis nélkül? Ha az „A hozzáférési pont” jelerőssége -70 dBm, az „B hozzáférési pont” pedig épp felugrik -69 dBm-re, az ESP32 azonnal lecsatlakozna az A-ról és átváltana a B-re. Egy másodperccel később a B visszaesik -71 dBm-re, az ESP32 megint visszavált. Ez folyamatos hálózati szakadozást okozna.

Hogyan véd meg a 12 dBm-es hiszterézis? Az ESP32 csak akkor indítja el a roamingot, ha a talált új hálózat jelerőssége legalább 12 dBm-mel erősebb, mint a jelenlegi hálózat átlagolt jelerőssége.

A hiszterézis önmagában még nem elegendő annak biztosítására, hogy az új hozzáférési pont valóban tartósan jobb kapcsolatot biztosítson. Ezért a program időalapú megerősítést (debounce) is alkalmaz.

A jelenlegi beállítások mellett ez két egymást követő hálózatkeresést jelent, amely 15 másodperces keresési periódussal számolva összesen körülbelül 30 másodpercnek felel meg.

void checkScanResults()
{
    ....
    // Csak akkor vizsgáljuk a roaming lehetőségét,
    // ha az új AP jelerőssége meghaladja a hiszterézis küszöbét
    if(bestNetworkIndex != -1 && bestScanRssi > averagedRssi + rssiHysteresis)
    {
      // Ellenőrizzük, hogy ugyanaz az AP több egymást követő
      // keresés során is a legjobb választás-e
      if(bestScanBssid == candidateBssid)
      {
        candidateCount++;
      }
      else
      {
        candidateBssid = bestScanBssid;
        candidateCount = 1;
      }
      ....

Ha a függvényben minden feltétel a fentiek szerint teljesül, indul a roaming. Az ESP32 bontja a meglévő kapcsolatot, megszakítja az MQTT kommunikációt, majd közvetlenül csatlakozik az új hozzáférési ponthoz.

Az MQTT kapcsolat bontása azért szükséges, mert a Wi-Fi kapcsolat megszakításával a TCP kapcsolat is érvénytelenné válik. Az új hozzáférési ponthoz történő csatlakozás után az MQTT kapcsolat automatikusan újraépül.

      ....
      // Ha a roaming feltételei teljesülnek,
      // átváltunk az új hozzáférési pontra
      if(candidateCount >= requiredStableScans)
      {
        if(debug)
        {
          Serial.printf("Roaming from %s to %s\n", currentBssid.c_str(), candidateBssid.c_str());
          Serial.println();
        }
        
        // beállítjuk a jelzőt, hogy folyamatban van a roamingolás
        isRoaming = true;

        int32_t channel = WiFi.channel(bestNetworkIndex);
        const uint8_t* bssid = WiFi.BSSID(bestNetworkIndex);

        // MQTT kapcsolat bontása roaming előtt
        if(mqttClient.connected()) mqttClient.disconnect();

        // Wi-Fi kapcsolat bontása
        WiFi.disconnect();

        // A keresési eredmények törlése, memória felszabadítása
        WiFi.scanDelete();
        
        isScanning = false;

        // Csatlakozás a kiválasztott AP-hoz
        connectToWifi(ssid, password, channel, bssid);
        return;
      }
      ....

Minden szkennelési ciklus után felszabadítja a memóriát a WiFi.scanDelete() függvény meghívásával és beállít egy jelzőt, hogy a hálózat keresése véget ért.

Ezzel lezárul az aktuális hálózatkeresési ciklus, majd a program a következő időzített keresésig a meglévő kapcsolaton folytatja a működést.


  // A keresési eredmények törlése, memória felszabadítása
  WiFi.scanDelete();

  // Keresés befejezése jelző beállítása
  isScanning = false;
  ....
}

MQTT Kapcsolat és Távvezérlés

Az MQTT kommunikáció során fontos követelmény, hogy a Wi-Fi hálózat pásztázása és a hozzáférési pontok közötti váltás (roaming) ne okozzon tartós kapcsolatvesztést. Amennyiben a roaming során az MQTT kapcsolat megszakad, a program automatikusan újraépíti azt, így a kommunikáció emberi beavatkozás nélkül helyreáll.

Az MQTT kapcsolat újraépítését a reconnectMqtt() függvény végzi. A hagyományos példaprogramokkal ellentétben a függvény nem használ blokkoló while ciklust. Ennek köszönhetően, ha az MQTT broker átmenetileg nem érhető el, a program nem áll meg várakozni, hanem folytatja a loop() ciklus végrehajtását. Ez biztosítja, hogy a Wi-Fi kapcsolat kezelése, a hálózatkeresés és a roaming folyamata zavartalanul működhessen.

void reconnectMqtt()
{
  // Csak aktív Wi-Fi kapcsolat mellett és hálózatkeresés közben ne
  // próbáljunk MQTT kapcsolatot létrehozni
  if(wifiConnected && !mqttClient.connected() && !isScanning)
  {
    if(debug) Serial.println("Connecting to the mqtt server...");

    // Kapcsolódás az MQTT brokerhez
    if(mqttClient.connect(mqttClientId))
    {
      if(debug)
      {
        Serial.println("Mqtt server is connected!");
        Serial.println();
      }
      ....

A program feliratkozik a debug témára, így távolról, egyetlen MQTT üzenettel ki/be kapcsolható a soros porti hibakeresési (Log) mód. Bekapcsolt állapotban a program részletes információkat ír ki a soros monitorra, ami jelentősen megkönnyíti a működés ellenőrzését és a hibák felderítését.

A program bővítese során itt iratkozhatunk fel további témákra.

      ....
      // Feliratkozás a szükséges topikokra
      mqttClient.subscribe(debugTopic);
      // feliratkozás további témákra...
    }
    else
    {
      if(debug)
      {
        Serial.print("Failed to connect to the mqtt server. | rc=");
        Serial.println(mqttClient.state());
        Serial.println();
      }
    }
  }
}

A beérkező MQTT üzenetek feldolgozását az mqttCallback() függvény végzi. A függvény automatikusan meghívódik minden olyan esetben, amikor az MQTT broker egy feliratkozott témára új üzenetet továbbít.

A jelenlegi megvalósításban a callback függvény a hibakeresési mód távoli vezérlését valósítja meg. A debug témára küldött true vagy false értékekkel a soros porti naplózás futás közben be- vagy kikapcsolható, így nincs szükség a program újrafordítására vagy újraindítására.

void mqttCallback(char* topic, byte* payload, unsigned int length)
{
  // A beérkezett topik és üzenet karakterlánccá alakítása
  String strTopic = String(topic);
  String strPayload = String((char*)payload, length);

  // A hibakeresési mód távoli vezérlése MQTT üzenettel
  if(strTopic == debugTopic)
  {
    if(strPayload == "true")
    {
      debug = true;
      Serial.println("Debug enabled!");
      Serial.println();
    }
    else if(strPayload == "false")
    {
      Serial.println("Debug disabled!");
      Serial.println();
      debug = false;
    }
  }
  // További MQTT üzenetek fogadásának kezelése...
}

A callback függvény felépítése lehetővé teszi, hogy később további MQTT parancsok kezelése is egyszerűen hozzáadható legyen, így a rendszer könnyen bővíthető új funkciókkal.

Az ESP32 működése során folyamatosan közzéteszi a legfontosabb állapotinformációkat, például az aktuális IP-címet és az átlagolt RSSI értéket. Ezeket dedikált MQTT témákba publikálja, így egy külső alkalmazás vagy felügyeleti rendszer folyamatosan nyomon követheti az eszköz állapotát.

A publikálás a checkScanResults() függvény végén történik. Ennek az az előnye, hogy roaming után a Wi-Fi kapcsolat és az MQTT kommunikáció már helyreállt, így a közzétett adatok mindig az aktuális hálózati állapotot tükrözik.

void checkScanResults()
{
   .....
  // Az aktuális állapot továbbítása MQTT-n
  if(mqttClient.connected())
  {
    mqttClient.publish(rssiTopic, String(averagedRssi).c_str());
    mqttClient.publish(ipTopic, WiFi.localIP().toString().c_str());

    if(debug)
    {
      Serial.print("Averaged RSSI: ");
      Serial.println(averagedRssi);
      Serial.print("IP: ");
      Serial.println(WiFi.localIP());
      Serial.println();
    }
  }
}

Indítási időbélyeg időzóna kezeléssel

Az első sikeres Wi-Fi és MQTT csatlakozást követően az eszköz lekéri a pontos dátumot és időt a pool.ntp.org NTP (Network Time Protocol) szerverről, majd beállítja a közép-európai időzónát (CET/CEST), amely automatikusan kezeli a nyári és téli időszámítás közötti átállást.

A lekért idő alapján a program egy formázott időbélyeget készít, amelyet MQTT üzenetként elküld a brokernek. Ez lehetővé teszi, hogy a szerver naplózza az eszköz indulásának pontos időpontját, ami megkönnyíti a rendszer működésének nyomon követését és az esetleges hibák utólagos elemzését.

void sendLocalTime()
{
  // Aktuális idő lekérése az NTP szerverről
  if(!getLocalTime(&timeinfo))
  {
    ....

  // Időbélyeg formázása
  strftime(Msg_Timestamp, sizeof(Msg_Timestamp), "%Y.%m.%d-%H:%M:%S", &timeinfo);
  ...

  // Az időbélyeg elküldése MQTT üzenetként
  mqttClient.publish(startTimeTopic, formattedTime);
}

Az NTP-alapú időszinkronizálás használata előtt konfigurálni kell a használt időzónát és az NTP szervert. Ez a setup() függvényben a configTzTime() függvény meghívásával tehetjük meg.

void setup()
{
  .....
  // Időszinkronizálás konfigurálása NTP szerver használatával
  configTzTime(timeZone, ntpServer);
}

Az időbélyeg elküldése csak egyszer, az első sikeres csatlakozást követően történik meg. A program ezt egy állapotjelző változó segítségével biztosítja, így az üzenet nem kerül ismételt elküldésre későbbi újracsatlakozások vagy roaming műveletek során.

void loop()
{
  ....
      mqttClient.loop();

      // Az indulási időbélyeg elküldése csak egyszer
      if(first)
      {
        sendLocalTime();
        first = false;
      }

   ....
}

A rendszer inicializálása

A setup() függvény a program indulásakor egyszer fut le, és elvégzi a rendszer működéséhez szükséges inicializálási feladatokat. Ennek során beállítja a soros kommunikációt, regisztrálja a Wi-Fi és MQTT eseménykezelő függvényeket, inicializálja a hálózati kapcsolatot, valamint konfigurálja az NTP-alapú időszinkronizálást.

void setup()
{
  // Soros kommunikáció inicializálása
  Serial.begin(115200);

  // Rövid várakozás a rendszer stabilizálódásához
  delay(2000);

  // Wi-Fi eseménykezelő regisztrálása
  WiFi.onEvent(WiFiEvent);

  // Wi-Fi kapcsolat inicializálása
  setupWifi();

  // MQTT szerver címének és portjának beállítása
  mqttClient.setServer(mqttServer, mqttPort);

  // MQTT callback függvény regisztrálása
  mqttClient.setCallback(mqttCallback);

  // Időszinkronizálás konfigurálása NTP szerver használatával
  configTzTime(timeZone, ntpServer);
}

Ezzel minden szükséges szolgáltatás készen áll a főprogram működéséhez.

A főprogram működése

A loop() függvény a program fő vezérlőciklusa, amely folyamatosan ismétlődik a rendszer működése során. Feladata a Wi-Fi és az MQTT kapcsolat felügyelete, a hálózatkeresés időzítése, a roaming folyamat vezérlése, valamint az egyéb időzített feladatok végrehajtása.

1. ábra: A loop() függvény működésének folyamatábrája. A főprogram folyamatosan felügyeli a Wi-Fi és MQTT kapcsolatokat, időzíti a hálózatkeresést, valamint szükség esetén végrehajtja a roaming folyamatot.

A főprogram nem alkalmazza a delay() függvény által okozott blokkoló várakozásokat, így a különböző feladatok egymástól függetlenül, folyamatosan hajthatók végre. Az időzítések megvalósítása a millis() függvény által visszaadott futási idő alapján történik, amely lehetővé teszi a nem blokkoló működést és a rendszer gyors reagálását az eseményekre.

Első lépésként a program ellenőrzi, hogy az ESP32 rendelkezik-e aktív Wi-Fi kapcsolattal. Amennyiben a kapcsolat megszakadt, és azt nem roaming művelet okozta, automatikusan újrakezdi a csatlakozást.

Ha a Wi-Fi kapcsolat aktív, a program meghatározott időközönként aszinkron hálózatkeresést indít. A keresés befejezése után kiértékeli az eredményeket, majd szükség esetén végrehajtja a hozzáférési pontok közötti váltást (roaming).

Ezzel párhuzamosan folyamatosan felügyeli az MQTT kapcsolat állapotát. Szükség esetén automatikusan újraépíti a kapcsolatot a brokerrel, feldolgozza a beérkező MQTT üzeneteket, valamint az első sikeres kapcsolódást követően elküldi az indítási időbélyeget is.

void loop()
{
  // Az aktuális futási idő lekérdezése
  currentMillis = millis();

  // Ha nincs Wi-Fi kapcsolat és nem roaming miatt szakadt meg,
  // újrakezdjük a kapcsolódást
  if(!wifiConnected && !isRoaming)
  {
    setupWifi();
    return;
  }
  else if(wifiConnected)
  {
    // Meghatározott időközönként új hálózatkeresés indítása
    if(currentMillis - prevScanMillis > wifiScanFreq)
    {
      prevScanMillis = currentMillis;
      startAsynchronousScan();
    }

    // A hálózatkeresés eredményének kiértékelése
    if(isScanning)
    {
      checkScanResults();
    }

    // MQTT kapcsolat helyreállítása, ha szükséges
    if(!mqttClient.connected())
    {
      reconnectMqtt();
    }
    else
    {
      // MQTT kommunikáció fenntartása
      mqttClient.loop();

      // Az indulási időbélyeg elküldése csak egyszer
      if(first)
      {
        sendLocalTime();
        first = false;
      }

      // További MQTT üzenetek küldése ide...
    }
  }
}

Melléklet

A teljes, kommentelt forráskód:

#include <WiFi.h>
#include <PubSubClient.h>
#include "time.h"

// Wi-Fi csatlakozási adatok
const char* ssid = "mySSID";
const char* password = "myPassword";

// MQTT szerver beállításai
const char* mqttServer = "192.168.1.2";
const uint16_t mqttPort = 1883;
const char* mqttClientId = "ESP32_Mesh_Node";

// MQTT témák (topik)
const char* rssiTopic = "esp32/RSSI";
const char* debugTopic = "esp32/DebugEnabled";
const char* ipTopic = "esp32/IP";
const char* newstartTopic = "esp32/Newstart";

// TCP és MQTT kliens objektum létrehozása
WiFiClient espClient;
PubSubClient mqttClient(espClient);

// Hibakeresési mód engedélyezése
bool debug = true;

// Időzítéshez szükséges változók
unsigned long currentMillis = 0;
unsigned long wifiScanFreq = 15000;     // Hálózatkeresés periódusa (15 sec)
unsigned long prevScanMillis = 0;

// Aktuális AP adatai
String currentBssid;
int8_t rssi;
bool isScanning = false;    // Jelzi, hogy folyamatban van-e hálózatkeresés

// RSSI mozgóátlag számításához szükséges változók
const int rssiSampleCount = 3;
int8_t rssiSamples[rssiSampleCount] = {0};
int rssiSampleIndex = 0;
int8_t averagedRssi = -100;

// Roaming döntéshez használt változók
String candidateBssid = "";
int candidateCount = 0;

// Az új AP-nak legalább két egymást követő keresés során 
// is a legjobbnak kell lennie!
const int requiredStableScans = 2;

// Az új AP csak akkor választható, ha legalább
// 12 dBm-mel jobb a jelenlegi kapcsolatnál
const int rssiHysteresis = 12;

// NTP szerver és időzóna beállításai
const char* ntpServer = "pool.ntp.org";
const char* timeZone = "CET-1CEST, M3.5.0, M10.5.0/3";

// Az indulási időbélyeg csak egyszer kerüljön elküldésre
bool first = true;

// Állapotjelző változók
bool wifiConnected = false;
bool isRoaming = false;


/*****************************************************************************/
// Wi-Fi eseménykezelő függvény.
// A rendszer automatikusan meghívja, amikor a Wi-Fi kapcsolat állapotában
// változás történik (pl. csatlakozás, IP-cím megszerzése vagy kapcsolatvesztés).
// Az esemény típusától függően frissíti a program állapotát és szükség esetén
// diagnosztikai információkat jelenít meg.
/*****************************************************************************/
void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info)
{
  switch(event)
  {
    // Sikeres csatlakozás a hozzáférési ponthoz
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      if(debug)
      {
        Serial.println();
        Serial.print("Connected to the ");
        Serial.println(WiFi.SSID().c_str());
        Serial.println(" Waiting for IP address...");
      }
      break;

    // Az eszköz IP-címet kapott, a kapcsolat használatra kész
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      wifiConnected = true;
      currentBssid = WiFi.BSSIDstr();
      if(debug)
      {
        Serial.println();
        Serial.println("IP address received!");
        Serial.print("IP: ");
        Serial.println(WiFi.localIP());
        Serial.print("BSSID: ");
        Serial.println(currentBssid);
        Serial.print("HostName: ");
        Serial.println(WiFi.getHostname());
        Serial.print("RSSI: ");
        Serial.println(WiFi.RSSI());
        Serial.println();
      }
      break;

    // Kapcsolat megszakadt, az állapotjelzők alaphelyzetbe állítása  
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      wifiConnected = false;
      isScanning = false;
      break;

    // Egyéb Wi-Fi események kezelése nem szükséges
    default:
      break;
  }
}


/*****************************************************************************/
// Kapcsolódás a megadott Wi-Fi hálózathoz.
// Normál csatlakozás esetén elegendő az SSID és a jelszó megadása,
// roaming során azonban lehetőség van egy konkrét BSSID és csatorna
// megadására is, amely gyorsabb AP-váltást tesz lehetővé.
/*****************************************************************************/
bool connectToWifi(const char* targetSsid, const char* targetPass, int32_t channel, const uint8_t* bssid)
{
  // Kapcsolati állapot alaphelyzetbe állítása
  wifiConnected = false;

  if(debug) Serial.print("Connecting to Wi-Fi...");

  if(bssid != NULL)  // Ha ismert a cél AP BSSID-je, akkor közvetlenül ahhoz csatlakozunk
  {
    WiFi.begin(targetSsid, targetPass, channel, bssid, true);
  }
  else  // Egyébként hagyjuk, hogy az ESP32 válasszon hozzáférési pontot
  {
    WiFi.begin(targetSsid, targetPass);
  }

  // Várakozás a sikeres csatlakozásra (legfeljebb 15 másodperc)
  int attempts = 0;
  while(!wifiConnected && attempts < 30)
  {
    delay(500);
    attempts++;
  }

  // Sikertelen kapcsolódás esetén hamis értékkel tér vissza
  if(!wifiConnected) 
  {
    return false;
  }

  // Sikeres kapcsolódás után újraindítjuk az RSSI átlagolását
  averagedRssi = -100;
  updateAveragedRssi();

  // Töröljük az előző roaming döntéshez tartozó adatokat
  candidateBssid = "";
  candidateCount = 0;

  // Jelezzük, hogy a roaming művelet befejeződött
  isRoaming = false;

  return true;
}


/*****************************************************************************/
// A Wi-Fi kapcsolat inicializálása.
// Törli az esetleges korábbi kapcsolatot, kliens (STA) módba állítja az ESP32-t,
// majd megkísérli a csatlakozást a megadott vezeték nélküli hálózathoz.
/*****************************************************************************/
void setupWifi()
{
  // Korábbi Wi-Fi kapcsolat törlése
  WiFi.disconnect(true);

  // Az ESP32 kliens (Station) üzemmódba állítása
  WiFi.mode(WIFI_STA);

  // Hálózatkeresés állapotának alaphelyzetbe állítása
  isScanning = false;

  // Csatlakozási kísérlet a megadott Wi-Fi hálózathoz
  if(!connectToWifi(ssid, password, 0, NULL))
  {
    if(debug)
    {
      Serial.println();
      Serial.println("Initial connection failed. Waiting for event retries.");
    } 
  }
}


/*****************************************************************************/
// Aszinkron Wi-Fi hálózatkeresés indítása.
// A függvény ellenőrzi, hogy indítható-e új keresés, majd a háttérben elindítja
// a környező hálózatok felderítését. A keresés nem blokkolja a program futását,
// így közben az egyéb feladatok (például MQTT kommunikáció) tovább működhetnek.
/*****************************************************************************/
void startAsynchronousScan()
{
  // Ha már fut keresés, vagy nincs aktív Wi-Fi kapcsolat,
  // akkor nincs szükség új keresés indítására
  if(isScanning || !wifiConnected) return;

  // Az aktuális kapcsolat jelerősségének frissítése
  // a roaming döntés előkészítéséhez
  updateAveragedRssi();

  // Keresés állapotjelző beállítása
  isScanning = true;

  if(debug) Serial.println("Network scan started...");

  // Aszinkron hálózatkeresés indítása
  // A program futása a keresés ideje alatt nem áll meg
  WiFi.scanNetworks(true, true, false);
}


/*****************************************************************************/
// Az aktuális RSSI érték frissítése és átlagolása.
// A vezeték nélküli jelerősség természetes ingadozása miatt a program több
// mérési eredményből számított átlagos értéket használ a roaming döntésekhez.
// Ez csökkenti a pillanatnyi jelerősség-változások hatását.
/*****************************************************************************/
void updateAveragedRssi()
{
  // Aktuális Wi-Fi jelerősség lekérdezése
  int8_t currentRssi = WiFi.RSSI();

  // Első mérés esetén minden mintát az aktuális értékkel töltünk fel,
  // így az átlag azonnal használható értéket ad
  if(averagedRssi == -100)
  {
    for(int i = 0; i < rssiSampleCount; i++)
    {
      rssiSamples[i] = currentRssi;
    }
    averagedRssi = currentRssi;
    return;
  }

  // Az új mérési eredmény eltárolása a körkörös mintatárolóban
  rssiSamples[rssiSampleIndex] = currentRssi;

  // Következő minta helyének meghatározása
  rssiSampleIndex = (rssiSampleIndex + 1) % rssiSampleCount;

  // A tárolt RSSI értékek összegzése
  int32_t sum = 0;
  for(int i = 0; i < rssiSampleCount; i++)
  {
    sum += rssiSamples[i];
  }

  // Átlagos RSSI érték kiszámítása
  averagedRssi = sum / rssiSampleCount;
}


/*****************************************************************************/
// A hálózatkeresés eredményeinek kiértékelése.
// A függvény kiválasztja a legkedvezőbb hozzáférési pontot, majd hiszterézis
// és stabilitási feltételek alapján eldönti, hogy szükséges-e roamingot
// végrehajtani.
/*****************************************************************************/
void checkScanResults()
{
  // Ellenőrizzük, hogy a hálózatkeresés befejeződött-e
  int16_t networks = WiFi.scanComplete();

  if(networks == WIFI_SCAN_RUNNING) return;

  // Sikertelen keresés esetén töröljük a keresés állapotát
  if(networks == WIFI_SCAN_FAILED)
  {
    if(debug)
    {
      Serial.println("Network scan failed!");
      Serial.println();
    }
    isScanning = false;
    return;
  }

  if(networks > 0)
  {
    // A keresés során talált legjobb AP adatainak tárolása
    int bestNetworkIndex = -1;
    int8_t bestScanRssi = -100;
    String bestScanBssid = "";

    if(debug)
    {
      Serial.print("Network scan completed. ");
      Serial.print(networks); 
      Serial.println(" networks found:");
    }

    // Az azonos SSID-jű hálózatok közül kiválasztjuk
    // a legerősebb, jelenleg nem használt hozzáférési pontot
    for(int i = 0; i < networks; i++)
    {
      if(WiFi.SSID(i) == ssid)
      {
        int8_t scanRssi = WiFi.RSSI(i);

        if(scanRssi > bestScanRssi && WiFi.BSSIDstr(i) != currentBssid)
        {
          bestScanRssi = scanRssi;
          bestScanBssid = WiFi.BSSIDstr(i);
          bestNetworkIndex = i;
        }
      }

      if(debug)
      {
        Serial.print(i + 1);
        Serial.print(". SSID: ");
        Serial.print(WiFi.SSID(i));
        Serial.print(" | RSSI: ");
        Serial.print(WiFi.RSSI(i));
        Serial.print(" | BSSID: ");
        Serial.println(WiFi.BSSIDstr(i));
      }
    }
    if(debug) Serial.println();

    // Csak akkor vizsgáljuk a roaming lehetőségét,
    // ha az új AP jelerőssége meghaladja a hiszterézis küszöbét
    if(bestNetworkIndex != -1 && bestScanRssi > averagedRssi + rssiHysteresis)
    {
      // Ellenőrizzük, hogy ugyanaz az AP több egymást követő
      // keresés során is a legjobb választás-e
      if(bestScanBssid == candidateBssid)
      {
        candidateCount++;
      }
      else
      {
        candidateBssid = bestScanBssid;
        candidateCount = 1;
      }

      // Ha a roaming feltételei teljesülnek,
      // átváltunk az új hozzáférési pontra
      if(candidateCount >= requiredStableScans)
      {
        if(debug)
        {
          Serial.printf("Roaming from %s to %s\n", currentBssid.c_str(), candidateBssid.c_str());
          Serial.println();
        }
        
        // beállítjuk a jelzőt, hogy folyamatban van a roamingolás
        isRoaming = true;

        int32_t channel = WiFi.channel(bestNetworkIndex);
        const uint8_t* bssid = WiFi.BSSID(bestNetworkIndex);

        // MQTT kapcsolat bontása roaming előtt
        if(mqttClient.connected()) mqttClient.disconnect();

        // Wi-Fi kapcsolat bontása
        WiFi.disconnect();

        // A keresési eredmények törlése, memória felszabadítása
        WiFi.scanDelete();
        
        isScanning = false;

        // Csatlakozás a kiválasztott AP-hoz
        connectToWifi(ssid, password, channel, bssid);
        return;
      }
    }
    else
    {
      // Ha nincs megfelelő roaming célpont,
      // töröljük az előző jelöltet
      candidateBssid = "";
      candidateCount = 0;
    }
  }

  // A keresési eredmények törlése, memória felszabadítása
  WiFi.scanDelete();

  // Keresés befejezése jelző beállítása
  isScanning = false;

  // Az aktuális állapot továbbítása MQTT-n
  if(mqttClient.connected())
  {
    mqttClient.publish(rssiTopic, String(averagedRssi).c_str());
    mqttClient.publish(ipTopic, WiFi.localIP().toString().c_str());

    if(debug)
    {
      Serial.print("Averaged RSSI: ");
      Serial.println(averagedRssi);
      Serial.print("IP: ");
      Serial.println(WiFi.localIP());
      Serial.println();
    }
  }
}


/*****************************************************************************/
// Az aktuális dátum és idő lekérése NTP szerverről.
// Sikeres időszinkronizálás után az időbélyeget formázza, majd MQTT üzenetként
// elküldi az eszköz indulási idejének naplózásához.
/*****************************************************************************/
void sendLocalTime()
{
  char Msg_Timestamp[20]; 
  struct tm timeinfo;

  // Aktuális idő lekérése az NTP szerverről
  if(!getLocalTime(&timeinfo))
  {
    if(debug)
    {                                                                                    
      Serial.println("Unable to retrieve the time from the NTP server!");
      Serial.println();
    }
    return;
  }
  else
  {
    if(debug) Serial.println("Time retrieved!");
  }

  // Az idő formázása
  strftime(Msg_Timestamp, sizeof(Msg_Timestamp), "%Y.%m.%d-%H:%M:%S", &timeinfo);

  if(debug) 
  {
    Serial.println(Msg_Timestamp);
    Serial.println();
  }

  // Az időbélyeg elküldése MQTT üzenetként
  mqttClient.publish(newstartTopic, Msg_Timestamp);
}


/*****************************************************************************/
// MQTT callback függvény.
// A függvényt az MQTT kliens automatikusan meghívja egy új üzenet érkezésekor.
// A beérkezett topik és üzenet tartalma alapján végrehajtja a szükséges
// műveleteket, jelenleg a hibakeresési mód távoli vezérlését valósítja meg.
// Később további funkciókhoz tartozó MQTT üzenetek feldolgozása is itt történik 
/*****************************************************************************/
void mqttCallback(char* topic, byte* payload, unsigned int length)
{
  // A beérkezett topik és üzenet karakterlánccá alakítása
  String strTopic = String(topic);
  String strPayload = String((char*)payload, length);

  // A hibakeresési mód távoli vezérlése MQTT üzenettel
  if(strTopic == debugTopic)
  {
    if(strPayload == "true")
    {
      debug = true;
      Serial.println("Debug enabled!");
      Serial.println();
    }
    else if(strPayload == "false")
    {
      Serial.println("Debug disabled!");
      Serial.println();
      debug = false;
    }
  }

  // További MQTT üzenetek kezelése...
}


/*****************************************************************************/
// MQTT kapcsolat újraépítése.
// Ha a Wi-Fi kapcsolat aktív, de az MQTT kapcsolat megszakadt, a függvény
// megkísérli az újracsatlakozást a brokerhez, majd siker esetén feliratkozik
// a szükséges MQTT topikokra.
/*****************************************************************************/
void reconnectMqtt()
{
  // Csak aktív Wi-Fi kapcsolat mellett és hálózatkeresés közben ne
  // próbáljunk MQTT kapcsolatot létrehozni
  if(wifiConnected && !mqttClient.connected() && !isScanning)
  {
    if(debug) Serial.println("Connecting to the mqtt server...");

    // Kapcsolódás az MQTT brokerhez
    if(mqttClient.connect(mqttClientId))
    {
      if(debug)
      {
        Serial.println("Mqtt server is connected!");
        Serial.println();
      }

      // Feliratkozás a szükséges topikokra
      mqttClient.subscribe(debugTopic);
    }
    else
    {
      if(debug)
      {
        Serial.print("Failed to connect to the mqtt server. | rc=");
        Serial.println(mqttClient.state());
        Serial.println();
      }
    }
  }
}


/*****************************************************************************/
// A program inicializálása
/*****************************************************************************/
void setup()
{
  // Soros kommunikáció inicializálása
  Serial.begin(115200);

  // Rövid várakozás a rendszer stabilizálódásához
  delay(2000);

  // Wi-Fi eseménykezelő regisztrálása
  WiFi.onEvent(WiFiEvent);

  // Wi-Fi kapcsolat inicializálása
  setupWifi();

  // MQTT szerver címének és portjának beállítása
  mqttClient.setServer(mqttServer, mqttPort);

  // MQTT callback függvény regisztrálása
  mqttClient.setCallback(mqttCallback);

  // Időszinkronizálás konfigurálása NTP szerver használatával
  configTzTime(timeZone, ntpServer);
}


/*****************************************************************************/
// A program fő ciklusa.
// Folyamatosan felügyeli a Wi-Fi és az MQTT kapcsolat állapotát, időzítetten
// elindítja a hálózatkeresést, kiértékeli annak eredményét, valamint gondoskodik
// a roaming és az MQTT kommunikáció folyamatos működéséről.
/*****************************************************************************/
void loop()
{
  // Az aktuális futási idő lekérdezése
  currentMillis = millis();

  // Ha nincs Wi-Fi kapcsolat és nem roaming miatt szakadt meg,
  // újrakezdjük a kapcsolódást
  if(!wifiConnected && !isRoaming)
  {
    setupWifi();
    return;
  }
  else if(wifiConnected)
  {
    // Meghatározott időközönként új hálózatkeresés indítása
    if(currentMillis - prevScanMillis > wifiScanFreq)
    {
      prevScanMillis = currentMillis;
      startAsynchronousScan();
    }

    // A hálózatkeresés eredményének kiértékelése
    if(isScanning)
    {
      checkScanResults();
    }

    // MQTT kapcsolat helyreállítása, ha szükséges
    if(!mqttClient.connected())
    {
      reconnectMqtt();
    }
    else
    {
      // MQTT kommunikáció fenntartása
      mqttClient.loop();

      // Az indulási időbélyeg elküldése csak egyszer
      if(first)
      {
        sendLocalTime();
        first = false;
      }

      // További MQTT üzenetek küldése ide...
    }
  }
}