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.
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.
Come funziona il sistema
Internet Monitor
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.
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.
Installare PlatformIO IDE
Nella barra laterale di Visual Studio Code apri Extensions e cerca:
PlatformIO IDE
Installa l’estensione e riavvia Visual Studio Code.
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:
git --version
Creare la cartella di lavoro
cd C:\
mkdir Meshtastic
cd Meshtastic
Scaricare il firmware Meshtastic
git clone --recurse-submodules https://github.com/meshtastic/firmware.git
Entra nella cartella:
cd C:\Meshtastic\firmware
Selezionare la versione Meshtastic utilizzata
Il progetto è stato sviluppato e testato con:
v2.7.24.472b14c
Passiamo quindi al relativo tag:
git checkout v2.7.24.472b14c
Creare un branch dedicato al progetto
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:
platformio.ini
Cerca:
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
e modifica in:
-DMESHTASTIC_EXCLUDE_REPLYBOT=0
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.
#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
#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.
& "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" run -e heltec-v3
Se tutto è corretto, PlatformIO terminerà con:
heltec-v3 SUCCESS
Collegare la Heltec e trovare la porta COM
Collega la Heltec direttamente al PC tramite USB.
Per visualizzare le porte disponibili:
& "$env:USERPROFILE\.platformio\penv\Scripts\platformio.exe" device list
Nel nostro caso il dispositivo appariva come:
COM3
Silicon Labs CP210x USB to UART Bridge
Caricare il firmware sulla Heltec V3
Supponendo che la scheda sia su COM3:
& "$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 |
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:
winget install Python.Python.3.13
Installare Meshtastic CLI
python -m pip install --upgrade meshtastic
Leggere la configurazione del nodo
meshtastic --port COM3 --info
Impostare l’SSID
meshtastic --port COM3 --set network.wifi_ssid "NOME_WIFI"
Come funziona il watchdog automatico
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.
CA18 INTERNET DOWN
WiFi: OFFLINE
Internet: DOWN
CA18 INTERNET RESTORED
Internet: ONLINE
DNS: OK
Downtime: 1m 4s
Latency: 31 ms
Comandi disponibili via LoRa
!status
!ping
!wifi
!dns
!history
!help
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.
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.
Esempio di risposta a !status
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.
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.

