HomeReti › Diagnostica e monitoraggio › Internet Monitor ESP32 LoRa
ESP32-S3 LoRa 868 MHz Meshtastic Heltec V3 Wi-Fi Progetto testato

Monitorare Internet con ESP32 e LoRa: avvisi automatici con Meshtastic

In questo progetto trasformiamo una Heltec WiFi LoRa 32 V3 in un monitor autonomo della connessione Internet. Il dispositivo controlla periodicamente Wi-Fi, DNS e raggiungibilità Internet e invia automaticamente tramite LoRa un messaggio quando la linea cade e quando torna disponibile.

Due dispositivi Meshnology Meshtastic utilizzati per monitorare Internet tramite LoRa
I due nodi Meshnology utilizzati nel progetto. Il CA18 controlla Internet mentre il secondo nodo riceve gli avvisi attraverso la rete LoRa Meshtastic.
15 s Intervallo watchdog
2 Errori consecutivi per DOWN
2 Successi consecutivi per RESTORED
868 MHz LoRa EU

Perché usare LoRa per monitorare Internet?

Controllare se Internet funziona con un ESP32 è relativamente semplice. Il problema nasce quando dobbiamo inviare una notifica proprio mentre la connessione che dovrebbe trasportare quella notifica è assente.

In questo progetto separiamo completamente la rete monitorata dal canale utilizzato per gli avvisi.

Il nodo CA18 utilizza il Wi-Fi soltanto per controllare la connessione Internet. Quando rileva un guasto, il messaggio viene trasmesso via LoRa attraverso Meshtastic e può essere ricevuto da un secondo nodo anche quando la WAN è completamente fuori servizio.

In poche parole Il sistema non utilizza Internet per comunicare che Internet è caduto. L’allarme passa su un collegamento LoRa completamente indipendente.

Come funziona il sistema

Internet Connessione da controllare
Wi-Fi 2,4 GHz Rete collegata a Internet
CA18 Heltec V3
Internet Monitor
LoRa LongFast 868 MHz
Secondo nodo Meshtastic
collegato allo smartphone

Hardware utilizzato

Componente Funzione
Heltec WiFi LoRa 32 V3 Nodo CA18 che controlla Internet
Secondo nodo Meshtastic Riceve gli avvisi LoRa
ESP32-S3 Gestisce Wi-Fi e firmware
SX1262 Radio LoRa
Rete Wi-Fi 2,4 GHz Connessione da monitorare
PC Windows Compilazione e caricamento firmware

Guida completa: partiamo da zero

La parte più interessante del progetto è che non utilizziamo un semplice sketch Arduino. Modifichiamo direttamente il firmware Meshtastic e lo ricompiliamo per la Heltec V3.

Non serve Arduino IDE Per compilare il firmware Meshtastic utilizzeremo Visual Studio Code, PlatformIO e Git.
1

Installare Visual Studio Code

Scarica e installa Visual Studio Code dal sito ufficiale Microsoft. Durante l’installazione puoi lasciare le opzioni predefinite.

Una volta terminata l’installazione, avvia Visual Studio Code.

2

Installare PlatformIO IDE

Nella barra laterale di Visual Studio Code apri Extensions e cerca:

Estensione VS Code
PlatformIO IDE

Installa l’estensione e riavvia Visual Studio Code.

Visual Studio Code con il firmware Meshtastic aperto tramite PlatformIO
Visual Studio Code con il progetto firmware Meshtastic aperto.
3

Installare Git for Windows

Git serve per scaricare il repository Meshtastic direttamente da GitHub.

Dopo l’installazione chiudi e riapri Visual Studio Code, quindi apri un terminale integrato.

Verifica Git con:

PowerShell
git --version
4

Creare la cartella di lavoro

PowerShell
cd C:\
mkdir Meshtastic
cd Meshtastic
5

Scaricare il firmware Meshtastic

PowerShell
git clone --recurse-submodules https://github.com/meshtastic/firmware.git

Entra nella cartella:

PowerShell
cd C:\Meshtastic\firmware
6

Selezionare la versione Meshtastic utilizzata

Il progetto è stato sviluppato e testato con:

Versione
v2.7.24.472b14c

Passiamo quindi al relativo tag:

PowerShell
git checkout v2.7.24.472b14c
7

Creare un branch dedicato al progetto

PowerShell
git switch -c internet-monitor-ca18

In questo modo possiamo modificare il firmware senza alterare direttamente il tag originale.

Abilitare ReplyBot

Meshtastic contiene già un modulo chiamato ReplyBot capace di intercettare messaggi testuali. Nel firmware utilizzato era disattivato durante la compilazione.

Apri:

File
platformio.ini

Cerca:

platformio.ini
-DMESHTASTIC_EXCLUDE_REPLYBOT=1

e modifica in:

platformio.ini
-DMESHTASTIC_EXCLUDE_REPLYBOT=0
Perché utilizziamo ReplyBot? Perché vogliamo continuare a sfruttare lo stack Meshtastic originale per radio, routing, cifratura e gestione dei pacchetti. Noi aggiungiamo soltanto la logica necessaria al monitoraggio Internet.

File ReplyBotModule.h

Il modulo deve ricevere i messaggi Meshtastic ma anche eseguire periodicamente il watchdog.

Per questo eredita sia da SinglePortModule sia da concurrency::OSThread.

src/modules/ReplyBotModule.h
#pragma once

#include "configuration.h"

#if !MESHTASTIC_EXCLUDE_REPLYBOT

#include "SinglePortModule.h"
#include "concurrency/OSThread.h"
#include "mesh/generated/meshtastic/mesh.pb.h"

class ReplyBotModule : public SinglePortModule, private concurrency::OSThread
{
  public:
    ReplyBotModule();

    void setup() override;

    bool wantPacket(const meshtastic_MeshPacket *p) override;

    ProcessMessage handleReceived(
        const meshtastic_MeshPacket &mp) override;

  protected:
    int32_t runOnce() override;

    bool isCommand(const char *msg) const;

    void sendDm(
        const meshtastic_MeshPacket &rx,
        const char *text);

    void sendLongFastBroadcast(
        const char *text);
};

#endif // MESHTASTIC_EXCLUDE_REPLYBOT

File ReplyBotModule.cpp completo

Configurazione finale testata Watchdog ogni 15 secondi, 2 errori consecutivi per dichiarare Internet DOWN e 2 controlli positivi consecutivi per dichiarare Internet RESTORED.
src/modules/ReplyBotModule.cpp
#include "configuration.h"

#if !MESHTASTIC_EXCLUDE_REPLYBOT

#include "Channels.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "ReplyBotModule.h"
#include "mesh/MeshTypes.h"

#include <Arduino.h>
#include <cctype>
#include <cstring>

#ifdef ARCH_ESP32
#include <WiFi.h>
#include <WiFiClient.h>
#endif


// ============================================================
// CONFIGURAZIONE INTERNET
// ============================================================

static const char *DNS_TEST_HOST = "example.com";

static const IPAddress INTERNET_TEST_IP(1, 1, 1, 1);

static constexpr uint16_t INTERNET_TEST_PORT = 443;

static constexpr uint8_t INTERNET_TEST_COUNT = 4;

static constexpr uint32_t TEST_DELAY_MS = 150;


// ============================================================
// WATCHDOG
// ============================================================

static constexpr uint32_t WATCHDOG_INTERVAL_MS =
    15 * 1000;

static constexpr uint32_t WATCHDOG_STARTUP_GRACE_MS =
    30 * 1000;

static constexpr uint8_t WATCHDOG_DOWN_CONFIRMATIONS = 2;

static constexpr uint8_t WATCHDOG_UP_CONFIRMATIONS = 2;

static uint8_t watchdogFailCount = 0;
static uint8_t watchdogSuccessCount = 0;

static bool watchdogDownAnnounced = false;

static uint32_t watchdogDownStartMs = 0;


// ============================================================
// RATE LIMIT
// ============================================================

struct ReplyBotCooldownEntry {
    uint32_t from = 0;
    uint32_t lastMs = 0;
};

static constexpr uint8_t REPLYBOT_COOLDOWN_SLOTS = 8;

static constexpr uint32_t REPLYBOT_DM_COOLDOWN_MS =
    15 * 1000;

static constexpr uint32_t REPLYBOT_LF_COOLDOWN_MS =
    60 * 1000;

static ReplyBotCooldownEntry
    replybotCooldown[REPLYBOT_COOLDOWN_SLOTS];

static uint8_t replybotCooldownIdx = 0;


// ============================================================
// STORICO
// ============================================================

static bool internetStateKnown = false;
static bool previousInternetState = false;

static uint32_t currentDownStartMs = 0;
static uint32_t lastDownDurationMs = 0;
static uint32_t lastDownDetectedMs = 0;
static uint32_t lastRecoveryMs = 0;


// ============================================================
// RISULTATO TEST
// ============================================================

struct InternetTestResult {

    bool wifiConnected = false;
    bool dnsOK = false;
    bool internetOK = false;

    int32_t wifiRSSI = 0;

    uint32_t latencyMin = 0;
    uint32_t latencyAvg = 0;
    uint32_t latencyMax = 0;

    uint8_t attempts = 0;
    uint8_t successes = 0;
    uint8_t lossPercent = 100;
};


// ============================================================
// RATE LIMIT
// ============================================================

static bool replybotRateLimited(
    uint32_t from,
    uint32_t cooldownMs)
{
    const uint32_t now = millis();

    for (auto &e : replybotCooldown) {

        if (e.from == from) {

            if ((uint32_t)(now - e.lastMs) < cooldownMs) {
                return true;
            }

            e.lastMs = now;
            return false;
        }
    }

    replybotCooldown[replybotCooldownIdx].from = from;
    replybotCooldown[replybotCooldownIdx].lastMs = now;

    replybotCooldownIdx =
        (replybotCooldownIdx + 1) %
        REPLYBOT_COOLDOWN_SLOTS;

    return false;
}


// ============================================================
// FORMATTA DURATA
// ============================================================

static void formatDuration(
    uint32_t durationMs,
    char *buffer,
    size_t bufferSize)
{
    uint32_t totalSeconds =
        durationMs / 1000;

    uint32_t days =
        totalSeconds / 86400;

    uint32_t hours =
        (totalSeconds % 86400) / 3600;

    uint32_t minutes =
        (totalSeconds % 3600) / 60;

    uint32_t seconds =
        totalSeconds % 60;

    if (days > 0) {

        snprintf(
            buffer,
            bufferSize,
            "%lud %luh %lum",
            (unsigned long)days,
            (unsigned long)hours,
            (unsigned long)minutes);

    } else if (hours > 0) {

        snprintf(
            buffer,
            bufferSize,
            "%luh %lum %lus",
            (unsigned long)hours,
            (unsigned long)minutes,
            (unsigned long)seconds);

    } else {

        snprintf(
            buffer,
            bufferSize,
            "%lum %lus",
            (unsigned long)minutes,
            (unsigned long)seconds);
    }
}


// ============================================================
// DNS
// ============================================================

static bool testDNS()
{
#ifdef ARCH_ESP32

    if (WiFi.status() != WL_CONNECTED) {
        return false;
    }

    IPAddress resolvedIP;

    int result =
        WiFi.hostByName(
            DNS_TEST_HOST,
            resolvedIP);

    return result == 1;

#else

    return false;

#endif
}


// ============================================================
// TEST INTERNET COMPLETO
// ============================================================

static InternetTestResult runInternetTest()
{
    InternetTestResult result;

#ifdef ARCH_ESP32

    result.wifiConnected =
        (WiFi.status() == WL_CONNECTED);

    if (!result.wifiConnected) {
        return result;
    }

    result.wifiRSSI =
        WiFi.RSSI();

    result.dnsOK =
        testDNS();

    result.attempts =
        INTERNET_TEST_COUNT;

    uint32_t latencyTotal = 0;
    uint32_t latencyMin = UINT32_MAX;
    uint32_t latencyMax = 0;

    for (uint8_t i = 0;
         i < INTERNET_TEST_COUNT;
         i++) {

        WiFiClient client;

        uint32_t startMs =
            millis();

        bool connected =
            client.connect(
                INTERNET_TEST_IP,
                INTERNET_TEST_PORT);

        uint32_t elapsedMs =
            millis() - startMs;

        if (connected) {

            result.successes++;

            latencyTotal +=
                elapsedMs;

            if (elapsedMs <
                latencyMin) {

                latencyMin =
                    elapsedMs;
            }

            if (elapsedMs >
                latencyMax) {

                latencyMax =
                    elapsedMs;
            }

            client.stop();
        }

        delay(TEST_DELAY_MS);
    }

    if (result.attempts > 0) {

        result.lossPercent =
            ((result.attempts -
              result.successes) * 100) /
            result.attempts;
    }

    if (result.successes > 0) {

        result.internetOK = true;

        result.latencyMin =
            latencyMin;

        result.latencyMax =
            latencyMax;

        result.latencyAvg =
            latencyTotal /
            result.successes;
    }

#endif

    return result;
}


// ============================================================
// STORICO
// ============================================================

static void updateInternetHistory(
    bool internetOnline)
{
    uint32_t now =
        millis();

    if (!internetStateKnown) {

        internetStateKnown = true;

        previousInternetState =
            internetOnline;

        if (!internetOnline) {

            currentDownStartMs =
                now;

            lastDownDetectedMs =
                now;
        }

        return;
    }

    if (previousInternetState &&
        !internetOnline) {

        currentDownStartMs =
            now;

        lastDownDetectedMs =
            now;
    }

    if (!previousInternetState &&
        internetOnline) {

        if (currentDownStartMs != 0) {

            lastDownDurationMs =
                now -
                currentDownStartMs;
        }

        lastRecoveryMs =
            now;

        currentDownStartMs =
            0;
    }

    previousInternetState =
        internetOnline;
}


// ============================================================
// COMANDI
// ============================================================

static bool commandEquals(
    const char *msg,
    const char *command)
{
    if (!msg ||
        !command) {

        return false;
    }

    while (*msg == ' ' ||
           *msg == '\t') {

        msg++;
    }

    size_t len =
        strlen(command);

    if (strncmp(
            msg,
            command,
            len) != 0) {

        return false;
    }

    char next =
        msg[len];

    return next == '\0' ||
           std::isspace(
             static_cast<unsigned char>(next));
}


// ============================================================
// COSTRUTTORE
// ============================================================

ReplyBotModule::ReplyBotModule()
    : SinglePortModule(
          "replybot",
          meshtastic_PortNum_TEXT_MESSAGE_APP),
      concurrency::OSThread(
          "CA18InternetMonitor",
          WATCHDOG_INTERVAL_MS)
{
    isPromiscuous = true;
}


// ============================================================
// SETUP
// ============================================================

void ReplyBotModule::setup()
{
    // Il Wi-Fi viene gestito direttamente da Meshtastic.
}


// ============================================================
// WATCHDOG
// ============================================================

int32_t ReplyBotModule::runOnce()
{
#ifdef ARCH_ESP32

    if (millis() <
        WATCHDOG_STARTUP_GRACE_MS) {

        return WATCHDOG_INTERVAL_MS;
    }

    bool internetOK = false;
    bool dnsOK = false;

    uint32_t latencyMs = 0;

    if (WiFi.status() ==
        WL_CONNECTED) {

        dnsOK =
            testDNS();

        WiFiClient client;

        uint32_t startMs =
            millis();

        bool connected =
            client.connect(
                INTERNET_TEST_IP,
                INTERNET_TEST_PORT);

        latencyMs =
            millis() -
            startMs;

        if (connected) {

            internetOK = true;

            client.stop();
        }
    }


    // --------------------------------------------------------
    // INTERNET ONLINE
    // --------------------------------------------------------

    if (internetOK) {

        watchdogFailCount = 0;

        if (!watchdogDownAnnounced) {

            watchdogSuccessCount = 0;

            return WATCHDOG_INTERVAL_MS;
        }

        if (watchdogSuccessCount <
            WATCHDOG_UP_CONFIRMATIONS) {

            watchdogSuccessCount++;
        }

        if (watchdogSuccessCount >=
            WATCHDOG_UP_CONFIRMATIONS) {

            uint32_t downtimeMs =
                millis() -
                watchdogDownStartMs;

            char duration[32];

            formatDuration(
                downtimeMs,
                duration,
                sizeof(duration));

            char message[220];

            snprintf(
                message,
                sizeof(message),

                "CA18 INTERNET RESTORED\n"
                "Internet: ONLINE\n"
                "DNS: %s\n"
                "Downtime: %s\n"
                "Latency: %lu ms",

                dnsOK
                    ? "OK"
                    : "FAIL",

                duration,

                (unsigned long)
                    latencyMs);

            sendLongFastBroadcast(
                message);

            lastDownDurationMs =
                downtimeMs;

            lastRecoveryMs =
                millis();

            currentDownStartMs =
                0;

            previousInternetState =
                true;

            internetStateKnown =
                true;

            watchdogDownAnnounced =
                false;

            watchdogSuccessCount =
                0;

            watchdogFailCount =
                0;

            watchdogDownStartMs =
                0;
        }

        return WATCHDOG_INTERVAL_MS;
    }


    // --------------------------------------------------------
    // INTERNET DOWN
    // --------------------------------------------------------

    watchdogSuccessCount = 0;

    if (watchdogDownAnnounced) {

        return WATCHDOG_INTERVAL_MS;
    }

    if (watchdogFailCount <
        WATCHDOG_DOWN_CONFIRMATIONS) {

        watchdogFailCount++;
    }

    if (watchdogFailCount >=
        WATCHDOG_DOWN_CONFIRMATIONS) {

        watchdogDownAnnounced =
            true;

        watchdogDownStartMs =
            millis();

        lastDownDetectedMs =
            millis();

        currentDownStartMs =
            millis();

        previousInternetState =
            false;

        internetStateKnown =
            true;

        char message[220];

        if (WiFi.status() !=
            WL_CONNECTED) {

            snprintf(
                message,
                sizeof(message),

                "CA18 INTERNET DOWN\n"
                "WiFi: OFFLINE\n"
                "Internet: DOWN");

        } else {

            snprintf(
                message,
                sizeof(message),

                "CA18 INTERNET DOWN\n"
                "WiFi: OK (%d dBm)\n"
                "DNS: %s\n"
                "Internet: DOWN",

                WiFi.RSSI(),

                dnsOK
                    ? "OK"
                    : "FAIL");
        }

        sendLongFastBroadcast(
            message);
    }

#endif

    return WATCHDOG_INTERVAL_MS;
}


// ============================================================
// FILTRO PACCHETTI
// ============================================================

bool ReplyBotModule::wantPacket(
    const meshtastic_MeshPacket *p)
{
    return (
        p &&
        p->decoded.portnum ==
            ourPortNum);
}


// ============================================================
// RICEZIONE MESSAGGI
// ============================================================

ProcessMessage ReplyBotModule::handleReceived(
    const meshtastic_MeshPacket &mp)
{
    const uint32_t ourNode =
        nodeDB->getNodeNum();

    const bool isDM =
        (mp.to ==
         ourNode);

    const bool isPrimaryChannel =
        (mp.channel ==
         channels.getPrimaryIndex()) &&
        isBroadcast(mp.to);

    if (!isDM &&
        !isPrimaryChannel) {

        return ProcessMessage::CONTINUE;
    }

    if (mp.decoded.payload.size ==
        0) {

        return ProcessMessage::CONTINUE;
    }

    char buf[260];

    memset(
        buf,
        0,
        sizeof(buf));

    size_t n =
        mp.decoded.payload.size;

    if (n >
        sizeof(buf) - 1) {

        n =
            sizeof(buf) - 1;
    }

    memcpy(
        buf,
        mp.decoded.payload.bytes,
        n);

    if (!isCommand(buf)) {

        return ProcessMessage::CONTINUE;
    }

    const uint32_t cooldownMs =
        isDM
            ? REPLYBOT_DM_COOLDOWN_MS
            : REPLYBOT_LF_COOLDOWN_MS;

    if (replybotRateLimited(
            mp.from,
            cooldownMs)) {

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // HELP
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!help")) {

        sendDm(
            mp,

            "CA18 Monitor\n"
            "!status stato completo\n"
            "!ping latenza/loss\n"
            "!dns test DNS\n"
            "!wifi stato WiFi\n"
            "!history ultimo down");

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // WIFI
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!wifi")) {

#ifdef ARCH_ESP32

        char reply[220];

        if (WiFi.status() !=
            WL_CONNECTED) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 WIFI\n"
                "Stato: OFFLINE");

        } else {

            String ip =
                WiFi.localIP()
                    .toString();

            String gateway =
                WiFi.gatewayIP()
                    .toString();

            snprintf(
                reply,
                sizeof(reply),

                "CA18 WIFI\n"
                "Stato: OK\n"
                "SSID: %s\n"
                "RSSI: %d dBm\n"
                "IP: %s\n"
                "Gateway: %s",

                WiFi.SSID().c_str(),

                WiFi.RSSI(),

                ip.c_str(),

                gateway.c_str());
        }

        sendDm(
            mp,
            reply);

#else

        sendDm(
            mp,
            "WiFi non disponibile");

#endif

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // DNS
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!dns")) {

        bool dnsOK =
            testDNS();

        sendDm(
            mp,

            dnsOK
                ? "CA18 DNS: OK"
                : "CA18 DNS: FAIL");

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // PING
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!ping")) {

        InternetTestResult result =
            runInternetTest();

        updateInternetHistory(
            result.internetOK);

        char reply[180];

        if (!result.wifiConnected) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 TEST\n"
                "WiFi: OFFLINE\n"
                "Internet: DOWN");

        } else if (!result.internetOK) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 TEST\n"
                "Internet: DOWN\n"
                "Loss: 100%%");

        } else {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 TEST\n"
                "Internet: ONLINE\n"
                "Latency min/avg/max:\n"
                "%lu/%lu/%lu ms\n"
                "Loss: %u%%",

                (unsigned long)
                    result.latencyMin,

                (unsigned long)
                    result.latencyAvg,

                (unsigned long)
                    result.latencyMax,

                result.lossPercent);
        }

        sendDm(
            mp,
            reply);

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // HISTORY
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!history")) {

        char reply[200];

        if (lastDownDetectedMs == 0) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 HISTORY\n"
                "Nessun downtime rilevato\n"
                "dall'avvio");

        } else if (!previousInternetState &&
                   currentDownStartMs != 0) {

            char duration[32];

            formatDuration(
                millis() -
                    currentDownStartMs,

                duration,
                sizeof(duration));

            snprintf(
                reply,
                sizeof(reply),

                "CA18 HISTORY\n"
                "Internet: DOWN\n"
                "Down da: %s",

                duration);

        } else {

            char duration[32];
            char ago[32];

            formatDuration(
                lastDownDurationMs,

                duration,
                sizeof(duration));

            formatDuration(
                millis() -
                    lastRecoveryMs,

                ago,
                sizeof(ago));

            snprintf(
                reply,
                sizeof(reply),

                "CA18 HISTORY\n"
                "Ultimo down: %s\n"
                "Durata: %s",

                ago,
                duration);
        }

        sendDm(
            mp,
            reply);

        return ProcessMessage::CONTINUE;
    }


    // --------------------------------------------------------
    // STATUS
    // --------------------------------------------------------

    if (commandEquals(
            buf,
            "!status")) {

        InternetTestResult result =
            runInternetTest();

        updateInternetHistory(
            result.internetOK);

        int hopsAway =
            getHopsAway(mp);

        int loraRSSI =
            mp.rx_rssi;

        if (loraRSSI > 0) {
            loraRSSI -= 200;
        }

        float loraSNR =
            mp.rx_snr;

        char reply[240];

        if (!result.wifiConnected) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 INTERNET MONITOR\n"
                "WiFi: OFFLINE\n"
                "Internet: DOWN\n"
                "Hops: %d\n"
                "LoRa: %d dBm %.1f dB",

                hopsAway,
                loraRSSI,
                loraSNR);

        } else if (!result.internetOK) {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 INTERNET MONITOR\n"
                "WiFi: OK (%ld dBm)\n"
                "DNS: %s\n"
                "Internet: DOWN\n"
                "Loss: 100%%\n"
                "Hops: %d\n"
                "LoRa: %d dBm %.1f dB",

                (long)
                    result.wifiRSSI,

                result.dnsOK
                    ? "OK"
                    : "FAIL",

                hopsAway,
                loraRSSI,
                loraSNR);

        } else {

            snprintf(
                reply,
                sizeof(reply),

                "CA18 INTERNET MONITOR\n"
                "WiFi: OK (%ld dBm)\n"
                "DNS: %s\n"
                "Internet: ONLINE\n"
                "Latency: %lu ms\n"
                "Loss: %u%%\n"
                "Hops: %d\n"
                "LoRa: %d dBm %.1f dB",

                (long)
                    result.wifiRSSI,

                result.dnsOK
                    ? "OK"
                    : "FAIL",

                (unsigned long)
                    result.latencyAvg,

                result.lossPercent,

                hopsAway,
                loraRSSI,
                loraSNR);
        }

        sendDm(
            mp,
            reply);

        return ProcessMessage::CONTINUE;
    }

    return ProcessMessage::CONTINUE;
}


// ============================================================
// COMANDI SUPPORTATI
// ============================================================

bool ReplyBotModule::isCommand(
    const char *msg) const
{
    if (!msg) {
        return false;
    }

    if (commandEquals(msg, "!status"))
        return true;

    if (commandEquals(msg, "!ping"))
        return true;

    if (commandEquals(msg, "!dns"))
        return true;

    if (commandEquals(msg, "!wifi"))
        return true;

    if (commandEquals(msg, "!history"))
        return true;

    if (commandEquals(msg, "!help"))
        return true;

    return false;
}


// ============================================================
// BROADCAST AUTOMATICO
// ============================================================

void ReplyBotModule::sendLongFastBroadcast(
    const char *text)
{
    if (!text) {
        return;
    }

    meshtastic_MeshPacket *p =
        allocDataPacket();

    p->to =
        NODENUM_BROADCAST;

    p->channel =
        channels.getPrimaryIndex();

    p->decoded.want_response =
        false;

    p->want_ack =
        false;

    size_t len =
        strlen(text);

    if (len >
        sizeof(
            p->decoded.payload.bytes)) {

        len =
            sizeof(
                p->decoded.payload.bytes);
    }

    p->decoded.payload.size =
        len;

    memcpy(
        p->decoded.payload.bytes,
        text,
        len);

    service->sendToMesh(p);
}


// ============================================================
// RISPOSTA
// ============================================================

void ReplyBotModule::sendDm(
    const meshtastic_MeshPacket &rx,
    const char *text)
{
    if (!text) {
        return;
    }

    meshtastic_MeshPacket *p =
        allocDataPacket();

    p->to =
        rx.from;

    p->channel =
        rx.channel;

    p->want_ack =
        false;

    p->decoded.want_response =
        false;

    size_t len =
        strlen(text);

    if (len >
        sizeof(
            p->decoded.payload.bytes)) {

        len =
            sizeof(
                p->decoded.payload.bytes);
    }

    p->decoded.payload.size =
        len;

    memcpy(
        p->decoded.payload.bytes,
        text,
        len);

    service->sendToMesh(p);
}

#endif // MESHTASTIC_EXCLUDE_REPLYBOT

Compilare il firmware

Salva i file modificati e compila l’environment della Heltec V3.

PowerShell
& "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" run -e heltec-v3

Se tutto è corretto, PlatformIO terminerà con:

Risultato
heltec-v3    SUCCESS

Collegare la Heltec e trovare la porta COM

Collega la Heltec direttamente al PC tramite USB.

Per visualizzare le porte disponibili:

PowerShell
& "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" device list

Nel nostro caso il dispositivo appariva come:

Esempio
COM3
Silicon Labs CP210x USB to UART Bridge
Attenzione La porta COM può cambiare. Controllala sempre prima di effettuare il flash.

Caricare il firmware sulla Heltec V3

Supponendo che la scheda sia su COM3:

Upload
& "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" run -e heltec-v3 -t upload --upload-port COM3

Attendi il messaggio SUCCESS prima di scollegare il dispositivo.

Configurare il Wi-Fi su Meshtastic

Non inseriamo SSID e password direttamente nel codice. Utilizziamo la configurazione Network nativa di Meshtastic.

WiFi Enabled ON
SSID Nome della rete 2,4 GHz da monitorare
Password Password Wi-Fi
Address Mode DHCP
Problema reale incontrato durante il test Uno spazio inserito accidentalmente alla fine del nome SSID causava continuamente l’errore Reason: 201 - NO_AP_FOUND. Per l’ESP32 un SSID con uno spazio finale è una rete differente.

Configurazione tramite Meshtastic CLI

Quando si abilita il Wi-Fi, il Bluetooth può essere disattivato. In questi casi la connessione seriale USB diventa molto utile.

Installare Python

Su Windows puoi utilizzare Winget:

PowerShell
winget install Python.Python.3.13

Installare Meshtastic CLI

PowerShell
python -m pip install --upgrade meshtastic

Leggere la configurazione del nodo

Meshtastic CLI
meshtastic --port COM3 --info

Impostare l’SSID

Meshtastic CLI
meshtastic --port COM3 --set network.wifi_ssid "NOME_WIFI"

Come funziona il watchdog automatico

Ogni 15 secondi Test leggero di raggiungibilità
2 FAIL Internet dichiarato DOWN
2 OK Internet dichiarato RESTORED

Due errori consecutivi riducono il rischio di falsi allarmi dovuti a un singolo timeout.

Dopo il primo messaggio DOWN il sistema continua a controllare la connessione ma non ripete continuamente l’allarme.

Messaggio DOWN
CA18 INTERNET DOWN
WiFi: OFFLINE
Internet: DOWN
Messaggio RESTORED
CA18 INTERNET RESTORED
Internet: ONLINE
DNS: OK
Downtime: 1m 4s
Latency: 31 ms

Comandi disponibili via LoRa

!status
Mostra stato Wi-Fi, DNS, Internet, latenza, loss e qualità del collegamento LoRa.
!ping
Esegue quattro tentativi TCP e restituisce latenza minima, media, massima e percentuale di tentativi falliti.
!wifi
Mostra SSID, RSSI, IP e gateway.
!dns
Verifica la risoluzione DNS.
!history
Mostra informazioni sull’ultimo downtime rilevato durante la sessione corrente.
!help
Mostra l’elenco dei comandi.

Test reali del progetto

Dopo la compilazione e il flash abbiamo testato realmente il sistema interrompendo e ripristinando la connessione.

  • Wi-Fi collegato correttamente.
  • DNS rilevato come operativo.
  • Internet rilevato ONLINE.
  • Latenza nell’ordine di poche decine di millisecondi.
  • Test completo con 0% di tentativi TCP falliti.
  • Spegnimento volontario della connessione Internet.
  • Ricezione automatica del messaggio INTERNET DOWN.
  • Nessuna ripetizione continua dell’allarme.
  • Riattivazione della connessione.
  • Ricezione automatica del messaggio INTERNET RESTORED.
  • Comandi !status e !ping ancora attivi durante il watchdog.

Messaggi ricevuti sullo smartphone

Questi screenshot mostrano il comportamento reale del sistema attraverso l’app Meshtastic.

Messaggio dello stato Internet ricevuto tramite Meshtastic sullo smartphone
Messaggi automatici Internet DOWN e RESTORED ricevuti tramite LoRa

Come vengono misurati latenza e Loss

Il progetto non utilizza un classico ICMP Echo come il comando ping di Windows.

Per verificare la raggiungibilità Internet viene tentata una connessione TCP verso l’indirizzo Cloudflare 1.1.1.1 sulla porta HTTPS 443.

La latenza rappresenta quindi il tempo necessario a stabilire la connessione TCP.

Importante Il valore Loss rappresenta la percentuale di tentativi TCP falliti e non il packet loss ICMP tradizionale.

Esempio di risposta a !status

Messaggio Meshtastic
CA18 INTERNET MONITOR
WiFi: OK (-51 dBm)
DNS: OK
Internet: ONLINE
Latency: 29 ms
Loss: 0%
Hops: 0
LoRa: -72 dBm 7.5 dB

Il vero vantaggio: un monitor fuori banda

La caratteristica più interessante del progetto non è semplicemente la capacità di verificare Internet.

Il vero vantaggio è avere un percorso di segnalazione completamente indipendente dalla rete che stiamo monitorando.

Concetto chiave Anche con modem, WAN o connessione ISP completamente fuori servizio, il collegamento LoRa tra i due nodi continua a funzionare.

Limiti della versione 1.0

Funzione Versione attuale Possibile evoluzione
Storico downtime RAM NVS persistente
Latenza Handshake TCP ICMP Echo reale
Loss Tentativi TCP Packet loss ICMP
Data e ora Durata relativa NTP / RTC
Server di test Cloudflare Più endpoint

Possibili evoluzioni del progetto

  • Storico persistente dei downtime.
  • Conteggio delle interruzioni giornaliere.
  • Statistiche sulle ultime 24 ore.
  • Verifica simultanea di più server.
  • Ping ICMP reale.
  • Dashboard sul display OLED.
  • Report periodici via LoRa.
  • Alimentazione tramite UPS o batteria.

Domande frequenti

Il CA18 deve avere Internet per inviare l’allarme?
No. L’avviso viene trasmesso tramite LoRa e può essere ricevuto anche quando Internet è completamente assente.
Perché due errori prima di dichiarare DOWN?
Un singolo timeout può essere temporaneo. Due fallimenti consecutivi riducono il rischio di falsi allarmi mantenendo comunque un tempo di rilevamento rapido.
Quanto tempo impiega a rilevare un guasto?
Il controllo viene eseguito ogni 15 secondi. Con due fallimenti consecutivi l’avviso arriva normalmente nell’ordine di circa 15-30 secondi, a seconda del momento in cui avviene il guasto rispetto al ciclo del watchdog.
L’allarme viene ripetuto ogni 15 secondi?
No. Il messaggio DOWN viene inviato una sola volta. Il nodo continua a controllare la linea in silenzio fino al ripristino della connessione.
Cosa significa Loss 0%?
Significa che tutti i tentativi TCP del test sono andati a buon fine. Non è una misura ICMP tradizionale.
Posso utilizzare un’altra scheda Meshtastic?
In linea di principio sì, ma environment PlatformIO, hardware e supporto Wi-Fi devono essere verificati per il modello specifico.