@alteriom/painlessmesh 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/CONTRIBUTING.md +51 -8
- package/examples/sendToInternet/README.md +54 -2
- package/examples/sendToInternet/callmebot.h +139 -0
- package/examples/sendToInternet/sendToInternet.ino +117 -39
- package/library.json +1 -1
- package/library.properties +1 -1
- package/package.json +1 -1
- package/src/AlteriomPainlessMesh.h +3 -3
- package/src/arduino/wifi.hpp +78 -62
- package/src/painlessMeshSTA.cpp +38 -4
- package/src/painlessMeshSTA.h +13 -1
- package/src/painlessmesh/buffer.hpp +11 -6
- package/src/painlessmesh/gateway.hpp +413 -71
- package/src/painlessmesh/layout.hpp +16 -0
- package/src/painlessmesh/mesh.hpp +251 -106
|
@@ -49,10 +49,24 @@
|
|
|
49
49
|
// For Callmebot WhatsApp API:
|
|
50
50
|
// - Get your API key from https://www.callmebot.com/blog/free-api-whatsapp-messages/
|
|
51
51
|
// - Format: https://api.callmebot.com/whatsapp.php?phone=PHONE&apikey=KEY&text=MESSAGE
|
|
52
|
+
// - CallMeBot is a free service with a rate limit, and it does not say in
|
|
53
|
+
// the HTTP status whether it will deliver: callmebot.h reads its reply.
|
|
54
|
+
// Send it alerts, not a message every minute.
|
|
55
|
+
//
|
|
56
|
+
// What painlessMesh does and does not do for you:
|
|
57
|
+
// - It issues each request once. It retries only when the request cannot
|
|
58
|
+
// have reached the server (the connection was refused, say) or the server
|
|
59
|
+
// said to come back (HTTP 429/503, honouring Retry-After). A timeout after
|
|
60
|
+
// the request was sent is NOT retried: resending could deliver it twice.
|
|
61
|
+
// - Every attempt carries the same X-Request-Id / Idempotency-Key header.
|
|
62
|
+
// - The callback gets the HTTP status and the start of the response body.
|
|
63
|
+
// Whether that reply means "delivered" is the service's language, so the
|
|
64
|
+
// sketch decides -- here with callmebot::judge().
|
|
52
65
|
//
|
|
53
66
|
//************************************************************
|
|
54
67
|
#include "painlessMesh.h"
|
|
55
|
-
|
|
68
|
+
|
|
69
|
+
#include "callmebot.h"
|
|
56
70
|
|
|
57
71
|
// ============================================
|
|
58
72
|
// Mesh Network Configuration
|
|
@@ -95,9 +109,18 @@
|
|
|
95
109
|
#define TEMP_RANGE 10.0 // Temperature range (20-30°C)
|
|
96
110
|
#define HUMIDITY_MIN 40.0 // Minimum humidity (%)
|
|
97
111
|
#define HUMIDITY_RANGE 40.0 // Humidity range (40-80%)
|
|
98
|
-
#define O2_MIN 5.0 //
|
|
99
|
-
#define
|
|
100
|
-
#define O2_ALARM_THRESHOLD 6.0 // O2 level below this
|
|
112
|
+
#define O2_MIN 5.0 // Lowest simulated O2 level (mg/L)
|
|
113
|
+
#define O2_MAX 10.0 // Highest simulated O2 level (mg/L)
|
|
114
|
+
#define O2_ALARM_THRESHOLD 6.0 // O2 level below this raises the alarm
|
|
115
|
+
#define O2_ALARM_CLEAR 6.5 // ...and it clears only above this (hysteresis)
|
|
116
|
+
|
|
117
|
+
// ============================================
|
|
118
|
+
// Alert pacing
|
|
119
|
+
// ============================================
|
|
120
|
+
// One WhatsApp per alarm, not one per reading, and never more often than
|
|
121
|
+
// this per node. A reading every minute that alerted every time it was low
|
|
122
|
+
// is how a sketch runs into CallMeBot's rate limit.
|
|
123
|
+
#define ALERT_MIN_INTERVAL_MS (10UL * 60UL * 1000UL)
|
|
101
124
|
|
|
102
125
|
// ============================================
|
|
103
126
|
// Mode Selection
|
|
@@ -115,7 +138,7 @@ painlessMesh mesh;
|
|
|
115
138
|
// ============================================
|
|
116
139
|
// Function Prototypes
|
|
117
140
|
// ============================================
|
|
118
|
-
|
|
141
|
+
bool sendAlertToWhatsApp(String message, bool isO2Alarm = false);
|
|
119
142
|
void sendSensorDataToCloud();
|
|
120
143
|
void receivedCallback(uint32_t from, String& msg);
|
|
121
144
|
void newConnectionCallback(uint32_t nodeId);
|
|
@@ -137,6 +160,13 @@ void sendStartupAlert();
|
|
|
137
160
|
Task taskStartupAlert(30000, TASK_FOREVER, &sendStartupAlert);
|
|
138
161
|
String startupMsg;
|
|
139
162
|
|
|
163
|
+
// Alert state
|
|
164
|
+
bool alertInFlight = false; // a WhatsApp request is on its way
|
|
165
|
+
uint32_t lastAlertMs = 0; // when the last one was handed over
|
|
166
|
+
uint32_t alertHoldMs = 0; // how long to wait after it
|
|
167
|
+
bool o2Alarm = false; // the alarm is raised
|
|
168
|
+
float o2Level = 8.0; // simulated reading, a slow random walk
|
|
169
|
+
|
|
140
170
|
// ============================================
|
|
141
171
|
// URL Encoding Helper
|
|
142
172
|
// ============================================
|
|
@@ -175,61 +205,97 @@ String urlEncode(const String& str) {
|
|
|
175
205
|
|
|
176
206
|
/**
|
|
177
207
|
* Send a WhatsApp message via Callmebot using sendToInternet()
|
|
178
|
-
*
|
|
208
|
+
*
|
|
179
209
|
* This function demonstrates how to use mesh.sendToInternet() to send
|
|
180
210
|
* data to an Internet endpoint. The request is automatically routed
|
|
181
211
|
* through a gateway node that has Internet access.
|
|
182
|
-
*
|
|
212
|
+
*
|
|
183
213
|
* @param message The message to send via WhatsApp
|
|
214
|
+
* @param isO2Alarm true for the O2 alarm, which is re-armed if CallMeBot
|
|
215
|
+
* does not take the message
|
|
216
|
+
* @return true when the request was handed to the mesh
|
|
184
217
|
*/
|
|
185
|
-
|
|
218
|
+
bool sendAlertToWhatsApp(String message, bool isO2Alarm) {
|
|
186
219
|
// Check if Internet is available via any gateway
|
|
187
220
|
if (!mesh.hasInternetConnection()) {
|
|
188
221
|
Serial.println("❌ No Internet available - no gateway with Internet found");
|
|
189
222
|
Serial.println(" Make sure at least one node is a bridge with router access");
|
|
190
|
-
return;
|
|
223
|
+
return false;
|
|
191
224
|
}
|
|
192
|
-
|
|
225
|
+
if (alertInFlight) {
|
|
226
|
+
Serial.println("(a WhatsApp message is still on its way; not sending another)");
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (lastAlertMs != 0 && millis() - lastAlertMs < alertHoldMs) {
|
|
230
|
+
Serial.printf("(WhatsApp paused for another %lu s after the last message)\n",
|
|
231
|
+
(unsigned long)((alertHoldMs - (millis() - lastAlertMs)) / 1000));
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
// URL-encode the message for safe transmission
|
|
194
236
|
String encodedMessage = urlEncode(message);
|
|
195
|
-
|
|
237
|
+
|
|
196
238
|
// Build the Callmebot WhatsApp API URL
|
|
197
239
|
// Format: https://api.callmebot.com/whatsapp.php?phone=PHONE&apikey=KEY&text=MESSAGE
|
|
198
240
|
String url = "https://api.callmebot.com/whatsapp.php";
|
|
199
|
-
url += "?phone=" +
|
|
241
|
+
url += "?phone=" + urlEncode(WHATSAPP_PHONE);
|
|
200
242
|
url += "&apikey=" + String(WHATSAPP_APIKEY);
|
|
201
243
|
url += "&text=" + encodedMessage;
|
|
202
|
-
|
|
244
|
+
|
|
203
245
|
Serial.println("\n📱 Sending WhatsApp message via sendToInternet()...");
|
|
204
246
|
Serial.printf(" Message: %s\n", message.c_str());
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
247
|
+
// Logs end up pasted into bug reports; the key stays out of them.
|
|
248
|
+
Serial.printf(" URL: %s\n", callmebot::redactApiKey(url).c_str());
|
|
249
|
+
|
|
250
|
+
// Use sendToInternet() to route the request through a gateway.
|
|
209
251
|
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
252
|
+
// This callback receives the whole InternetResult: the HTTP status, the
|
|
253
|
+
// start of CallMeBot's reply, whether the library could retry it and how
|
|
254
|
+
// many requests it took. painlessMesh says what HTTP says; callmebot::judge()
|
|
255
|
+
// says what CallMeBot meant -- a "Too many requests" page can arrive under
|
|
256
|
+
// HTTP 201, and HTTP 208 has meant "not delivered".
|
|
257
|
+
alertInFlight = true;
|
|
258
|
+
lastAlertMs = millis();
|
|
259
|
+
alertHoldMs = ALERT_MIN_INTERVAL_MS;
|
|
215
260
|
uint32_t msgId = mesh.sendToInternet(
|
|
216
261
|
url,
|
|
217
262
|
"", // No payload needed for GET request - params are in URL
|
|
218
|
-
[](
|
|
219
|
-
|
|
220
|
-
|
|
263
|
+
[isO2Alarm](const painlessmesh::InternetResult& result) {
|
|
264
|
+
alertInFlight = false;
|
|
265
|
+
const auto reply = callmebot::judge(result.httpStatus, result.response);
|
|
266
|
+
if (reply.accepted) {
|
|
267
|
+
Serial.printf("✅ WhatsApp message queued by CallMeBot (request %u, HTTP %u, %u attempt(s))\n",
|
|
268
|
+
result.messageId, result.httpStatus, result.attempts);
|
|
221
269
|
} else {
|
|
222
|
-
Serial.printf("❌
|
|
270
|
+
Serial.printf("❌ WhatsApp message not sent (request %u): %s\n",
|
|
271
|
+
result.messageId, reply.meaning);
|
|
272
|
+
Serial.printf(" HTTP %u after %u attempt(s)\n", result.httpStatus, result.attempts);
|
|
273
|
+
if (result.error.length() > 0) {
|
|
274
|
+
Serial.printf(" Error: %s\n", result.error.c_str());
|
|
275
|
+
}
|
|
276
|
+
if (result.response.length() > 0) {
|
|
277
|
+
Serial.printf(" CallMeBot said: %s\n", result.response.c_str());
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (reply.holdOffMs > alertHoldMs) {
|
|
281
|
+
alertHoldMs = reply.holdOffMs;
|
|
282
|
+
}
|
|
283
|
+
// An alarm CallMeBot did not take is still an alarm nobody has seen:
|
|
284
|
+
// re-arm it, so a later reading sends it again once the pause is over.
|
|
285
|
+
if (isO2Alarm && !reply.accepted) {
|
|
286
|
+
o2Alarm = false;
|
|
223
287
|
}
|
|
224
288
|
},
|
|
225
289
|
static_cast<uint8_t>(painlessmesh::gateway::GatewayPriority::PRIORITY_HIGH)
|
|
226
290
|
);
|
|
227
|
-
|
|
291
|
+
|
|
228
292
|
if (msgId > 0) {
|
|
229
|
-
Serial.printf("
|
|
230
|
-
|
|
231
|
-
Serial.println(" ❌ Failed to queue message - no gateway available");
|
|
293
|
+
Serial.printf(" Handed to the mesh as request %u; the result follows when the gateway answers\n", msgId);
|
|
294
|
+
return true;
|
|
232
295
|
}
|
|
296
|
+
// The callback reports why, from the scheduler, in a moment.
|
|
297
|
+
Serial.println(" Not handed to the mesh");
|
|
298
|
+
return false;
|
|
233
299
|
}
|
|
234
300
|
|
|
235
301
|
/**
|
|
@@ -243,7 +309,10 @@ void sendSensorDataToCloud() {
|
|
|
243
309
|
// Simulate sensor readings using configured ranges
|
|
244
310
|
float temperature = TEMP_MIN + random(0, (int)(TEMP_RANGE * 10)) / 10.0;
|
|
245
311
|
float humidity = HUMIDITY_MIN + random(0, (int)(HUMIDITY_RANGE * 10)) / 10.0;
|
|
246
|
-
|
|
312
|
+
// O2 drifts rather than jumping, so an alarm is an episode, not a coin toss
|
|
313
|
+
o2Level += random(-3, 4) / 10.0;
|
|
314
|
+
if (o2Level < O2_MIN) o2Level = O2_MIN;
|
|
315
|
+
if (o2Level > O2_MAX) o2Level = O2_MAX;
|
|
247
316
|
|
|
248
317
|
// Create JSON payload
|
|
249
318
|
String payload = "{";
|
|
@@ -257,11 +326,15 @@ void sendSensorDataToCloud() {
|
|
|
257
326
|
Serial.println("\n📊 Sending sensor data to cloud...");
|
|
258
327
|
Serial.printf(" Payload: %s\n", payload.c_str());
|
|
259
328
|
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
329
|
+
// Alert once when O2 falls below the threshold; re-arm once it recovers.
|
|
330
|
+
// An alert that could not be sent (no gateway yet, paused after a refusal)
|
|
331
|
+
// is tried again at the next reading while the alarm lasts.
|
|
332
|
+
if (!o2Alarm && o2Level < O2_ALARM_THRESHOLD) {
|
|
263
333
|
String alertMsg = "⚠️ ALARM: O2 level critical at " + String(o2Level, 1) + " mg/L! Node: " + String(mesh.getNodeId());
|
|
264
|
-
sendAlertToWhatsApp(alertMsg);
|
|
334
|
+
o2Alarm = sendAlertToWhatsApp(alertMsg, true);
|
|
335
|
+
} else if (o2Alarm && o2Level > O2_ALARM_CLEAR) {
|
|
336
|
+
Serial.printf(" O2 back to %.1f mg/L; alarm cleared\n", o2Level);
|
|
337
|
+
o2Alarm = false;
|
|
265
338
|
}
|
|
266
339
|
|
|
267
340
|
// Only send if Internet is available
|
|
@@ -299,7 +372,7 @@ void sendSensorDataToCloud() {
|
|
|
299
372
|
/**
|
|
300
373
|
* Send the startup notification once a gateway with Internet is known.
|
|
301
374
|
*
|
|
302
|
-
* Runs every 30 s from taskStartupAlert until
|
|
375
|
+
* Runs every 30 s from taskStartupAlert until the message has been handed to
|
|
303
376
|
* sendToInternet(), then disables itself.
|
|
304
377
|
*/
|
|
305
378
|
void sendStartupAlert() {
|
|
@@ -307,8 +380,9 @@ void sendStartupAlert() {
|
|
|
307
380
|
Serial.println("(startup WhatsApp waiting for a gateway with Internet; retrying in 30 s)");
|
|
308
381
|
return;
|
|
309
382
|
}
|
|
310
|
-
sendAlertToWhatsApp(startupMsg)
|
|
311
|
-
|
|
383
|
+
if (sendAlertToWhatsApp(startupMsg)) {
|
|
384
|
+
taskStartupAlert.disable();
|
|
385
|
+
}
|
|
312
386
|
}
|
|
313
387
|
|
|
314
388
|
void receivedCallback(uint32_t from, String& msg) {
|
|
@@ -386,7 +460,11 @@ void setup() {
|
|
|
386
460
|
// First attempt in 30 s, then every 30 s until a gateway with Internet is
|
|
387
461
|
// known: a bridge can use its own uplink at once, a regular node has to
|
|
388
462
|
// find the mesh first.
|
|
389
|
-
|
|
463
|
+
// A boot tag makes every start's text different: a service that drops a
|
|
464
|
+
// repeat of the last message (or answers it with HTTP 208) will not
|
|
465
|
+
// swallow this one because the previous boot sent the same words.
|
|
466
|
+
startupMsg = "🚀 Node " + String(mesh.getNodeId()) + " started (boot " +
|
|
467
|
+
String((uint32_t)random(0x10000), HEX) + ")";
|
|
390
468
|
Serial.println("Will send the startup WhatsApp as soon as a gateway with Internet is known (first try in 30 s)...\n");
|
|
391
469
|
userScheduler.addTask(taskStartupAlert);
|
|
392
470
|
taskStartupAlert.enableDelayed();
|
package/library.json
CHANGED
package/library.properties
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name=Alteriom PainlessMesh
|
|
2
|
-
version=2.0
|
|
2
|
+
version=2.1.0
|
|
3
3
|
author=Coopdis,Scotty Franzyshen,Edwin van Leeuwen,Germán Martín,Maximilian Schwarz,Doanh Doanh,Alteriom
|
|
4
4
|
maintainer=Alteriom
|
|
5
5
|
sentence=A painless way to setup a mesh with ESP8266 and ESP32 devices with Alteriom extensions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alteriom/painlessmesh",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "painlessMesh is a user-friendly library for creating mesh networks with ESP8266 and ESP32 devices. This Alteriom fork includes additional packages for sensor data (SensorPackage), device commands (CommandPackage), and status monitoring (StatusPackage). It handles routing and network management automatically, so you can focus on your application. The library uses JSON-based messaging and syncs time across all nodes, making it ideal for coordinated behaviour like synchronized light displays or sensor networks reporting to a central node.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"arduino",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
/**
|
|
30
30
|
* @brief AlteriomPainlessMesh library version information
|
|
31
31
|
*/
|
|
32
|
-
#define ALTERIOM_PAINLESS_MESH_VERSION "2.0
|
|
32
|
+
#define ALTERIOM_PAINLESS_MESH_VERSION "2.1.0"
|
|
33
33
|
#define ALTERIOM_PAINLESS_MESH_VERSION_MAJOR 2
|
|
34
|
-
#define ALTERIOM_PAINLESS_MESH_VERSION_MINOR
|
|
35
|
-
#define ALTERIOM_PAINLESS_MESH_VERSION_PATCH
|
|
34
|
+
#define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 1
|
|
35
|
+
#define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 0
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* @brief Library description and usage information
|
package/src/arduino/wifi.hpp
CHANGED
|
@@ -2727,41 +2727,44 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2727
2727
|
|
|
2728
2728
|
#if defined(ESP32) || defined(ESP8266)
|
|
2729
2729
|
/**
|
|
2730
|
-
*
|
|
2731
|
-
*
|
|
2732
|
-
*
|
|
2733
|
-
*
|
|
2734
|
-
*
|
|
2735
|
-
*
|
|
2736
|
-
* read from the stream, waiting at most GATEWAY_RESPONSE_HEAD_TIMEOUT_MS,
|
|
2737
|
-
* which keeps a large or chunked page from eating the heap or the
|
|
2738
|
-
* cooperative scheduler.
|
|
2730
|
+
* The start and the end of the response body (gateway::ResponseExcerpt),
|
|
2731
|
+
* read from the stream for at most GATEWAY_RESPONSE_HEAD_TIMEOUT_MS and
|
|
2732
|
+
* GATEWAY_RESPONSE_SCAN_BYTES. A body that fits whole is read whole. A
|
|
2733
|
+
* chunked body is read through gateway::ChunkedBodyDecoder: the raw stream
|
|
2734
|
+
* carries the framing, which getString() would remove but only by keeping
|
|
2735
|
+
* the whole body.
|
|
2739
2736
|
*/
|
|
2740
|
-
static TSTRING
|
|
2737
|
+
static TSTRING readResponseExcerpt(HTTPClient& http) {
|
|
2741
2738
|
const int size = http.getSize();
|
|
2742
|
-
if (size >= 0 &&
|
|
2739
|
+
if (size >= 0 &&
|
|
2740
|
+
static_cast<size_t>(size) <=
|
|
2741
|
+
gateway::GATEWAY_RESPONSE_HEAD_BYTES + gateway::GATEWAY_RESPONSE_TAIL_BYTES) {
|
|
2743
2742
|
return http.getString();
|
|
2744
2743
|
}
|
|
2745
|
-
|
|
2744
|
+
gateway::ResponseExcerpt excerpt;
|
|
2746
2745
|
WiFiClient* stream = http.getStreamPtr();
|
|
2747
|
-
if (stream == nullptr) return
|
|
2746
|
+
if (stream == nullptr) return excerpt.text();
|
|
2747
|
+
const bool chunked =
|
|
2748
|
+
size < 0 && gateway::transferEncodingIsChunked(http.header("Transfer-Encoding"));
|
|
2749
|
+
gateway::ChunkedBodyDecoder decoder(excerpt);
|
|
2748
2750
|
const uint32_t deadline =
|
|
2749
2751
|
millis() + gateway::GATEWAY_RESPONSE_HEAD_TIMEOUT_MS;
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
+
bool more = true;
|
|
2753
|
+
while (more && static_cast<int32_t>(deadline - millis()) > 0) {
|
|
2752
2754
|
int available = stream->available();
|
|
2753
2755
|
if (available <= 0) {
|
|
2754
2756
|
if (!stream->connected()) break;
|
|
2755
2757
|
delay(1);
|
|
2756
2758
|
continue;
|
|
2757
2759
|
}
|
|
2758
|
-
while (available-- > 0
|
|
2760
|
+
while (more && available-- > 0) {
|
|
2759
2761
|
const int c = stream->read();
|
|
2760
2762
|
if (c < 0) break;
|
|
2761
|
-
|
|
2763
|
+
more = chunked ? decoder.add(static_cast<char>(c))
|
|
2764
|
+
: excerpt.add(static_cast<char>(c));
|
|
2762
2765
|
}
|
|
2763
2766
|
}
|
|
2764
|
-
return
|
|
2767
|
+
return excerpt.text();
|
|
2765
2768
|
}
|
|
2766
2769
|
#endif
|
|
2767
2770
|
|
|
@@ -2836,8 +2839,13 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2836
2839
|
// that genuinely stopped answering NODE_SYNC still gets reaped even
|
|
2837
2840
|
// under continuous gateway traffic from other peers.
|
|
2838
2841
|
const auto blockingStartedMs = millis();
|
|
2842
|
+
// `retryable` is the gateway's word to the origin node on whether
|
|
2843
|
+
// the identical request may be sent again: 0 whenever it may have
|
|
2844
|
+
// reached the server, so a retry cannot deliver it twice.
|
|
2839
2845
|
auto finish = [this, &pkg, ingress, blockingStartedMs](
|
|
2840
|
-
bool ok, uint16_t code, const TSTRING& err
|
|
2846
|
+
bool ok, uint16_t code, const TSTRING& err,
|
|
2847
|
+
int8_t retryable, const TSTRING& response = TSTRING(),
|
|
2848
|
+
uint32_t retryAfterMs = 0) {
|
|
2841
2849
|
const auto stalledMs = millis() - blockingStartedMs;
|
|
2842
2850
|
auto refreshed = gateway::refreshPeerWatchdogs(*this, stalledMs);
|
|
2843
2851
|
if (refreshed > 0) {
|
|
@@ -2847,27 +2855,28 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2847
2855
|
static_cast<unsigned>(refreshed),
|
|
2848
2856
|
static_cast<unsigned long>(stalledMs));
|
|
2849
2857
|
}
|
|
2850
|
-
this->sendGatewayAck(pkg, ok, code, err, ingress
|
|
2858
|
+
this->sendGatewayAck(pkg, ok, code, err, ingress, response, retryable,
|
|
2859
|
+
retryAfterMs);
|
|
2851
2860
|
};
|
|
2852
2861
|
|
|
2853
2862
|
// Check Internet connectivity
|
|
2854
2863
|
// First check WiFi status for quick fail
|
|
2855
2864
|
if (WiFi.status() != WL_CONNECTED) {
|
|
2856
|
-
finish(false, 0, "Gateway WiFi not connected");
|
|
2865
|
+
finish(false, 0, "Gateway WiFi not connected", 0);
|
|
2857
2866
|
return true; // Consume package - we handled it (with error)
|
|
2858
2867
|
}
|
|
2859
2868
|
|
|
2860
2869
|
// Then check actual internet access (DNS resolution)
|
|
2861
2870
|
// This detects when WiFi is connected but router has no internet
|
|
2862
2871
|
if (!hasActualInternetAccess()) {
|
|
2863
|
-
finish(false, 0, "Router has no internet access - check WAN connection");
|
|
2872
|
+
finish(false, 0, "Router has no internet access - check WAN connection", 0);
|
|
2864
2873
|
return true; // Consume package - we handled it (with error)
|
|
2865
2874
|
}
|
|
2866
2875
|
|
|
2867
2876
|
// Finally, check for captive portal interference
|
|
2868
2877
|
// This detects when DNS works but HTTP requests are intercepted
|
|
2869
2878
|
if (!detectCaptivePortal()) {
|
|
2870
|
-
finish(false, 0, "Captive portal detected - requires web authentication. Check router/WiFi settings");
|
|
2879
|
+
finish(false, 0, "Captive portal detected - requires web authentication. Check router/WiFi settings", 0);
|
|
2871
2880
|
return true; // Consume package - we handled it (with error)
|
|
2872
2881
|
}
|
|
2873
2882
|
|
|
@@ -2899,7 +2908,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2899
2908
|
(gateway::GATEWAY_DNS_NEGATIVE_TTL_MS - failedAgoMs) /
|
|
2900
2909
|
1000));
|
|
2901
2910
|
Log(ERROR, "%s\n", dnsBuf);
|
|
2902
|
-
finish(false, 0, TSTRING(dnsBuf));
|
|
2911
|
+
finish(false, 0, TSTRING(dnsBuf), 0);
|
|
2903
2912
|
return true;
|
|
2904
2913
|
}
|
|
2905
2914
|
IPAddress resolved;
|
|
@@ -2909,7 +2918,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2909
2918
|
snprintf(dnsBuf, sizeof(dnsBuf), "DNS lookup failed for %s",
|
|
2910
2919
|
host.c_str());
|
|
2911
2920
|
Log(ERROR, "%s\n", dnsBuf);
|
|
2912
|
-
finish(false, 0, TSTRING(dnsBuf));
|
|
2921
|
+
finish(false, 0, TSTRING(dnsBuf), 0);
|
|
2913
2922
|
return true;
|
|
2914
2923
|
}
|
|
2915
2924
|
}
|
|
@@ -2959,6 +2968,17 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2959
2968
|
#endif
|
|
2960
2969
|
}
|
|
2961
2970
|
|
|
2971
|
+
// The same id on every attempt at this call. A service that
|
|
2972
|
+
// honours Idempotency-Key treats a retry as the request it already
|
|
2973
|
+
// has; anything recording requests can count the copies.
|
|
2974
|
+
const TSTRING requestId =
|
|
2975
|
+
gateway::requestIdFor(pkg.originNode, pkg.messageId, pkg.requestNonce);
|
|
2976
|
+
http.addHeader("X-Request-Id", requestId.c_str());
|
|
2977
|
+
http.addHeader("Idempotency-Key", requestId.c_str());
|
|
2978
|
+
// Not const: both cores declare collectHeaders(const char* keys[], ...).
|
|
2979
|
+
const char* collected[] = {"Retry-After", "Transfer-Encoding"};
|
|
2980
|
+
http.collectHeaders(collected, 2);
|
|
2981
|
+
|
|
2962
2982
|
// Make request (GET if no payload, POST if payload)
|
|
2963
2983
|
if (pkg.payload.length() > 0) {
|
|
2964
2984
|
http.addHeader("Content-Type", pkg.contentType.c_str());
|
|
@@ -2967,11 +2987,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2967
2987
|
httpCode = http.GET();
|
|
2968
2988
|
}
|
|
2969
2989
|
|
|
2970
|
-
//
|
|
2971
|
-
//
|
|
2972
|
-
//
|
|
2973
|
-
//
|
|
2974
|
-
//
|
|
2990
|
+
// HTTP semantics only: 200/201/202/204 succeed, anything else fails,
|
|
2991
|
+
// and a retry is allowed only when it cannot deliver the request
|
|
2992
|
+
// twice (see gateway::classifyHttpResult). Whether a service's reply
|
|
2993
|
+
// means what the application wanted is the application's decision,
|
|
2994
|
+
// so the start of the body goes back on success as well as failure.
|
|
2975
2995
|
//
|
|
2976
2996
|
// 3xx redirects are not automatically followed.
|
|
2977
2997
|
//
|
|
@@ -2981,72 +3001,68 @@ class Mesh : public painlessmesh::Mesh<Connection> {
|
|
|
2981
3001
|
TSTRING responseHead;
|
|
2982
3002
|
if (httpCode > 0) {
|
|
2983
3003
|
responseHead =
|
|
2984
|
-
|
|
3004
|
+
readResponseExcerpt(http);
|
|
2985
3005
|
}
|
|
2986
3006
|
const auto outcome =
|
|
2987
3007
|
gateway::classifyHttpResult(httpCode, responseHead);
|
|
2988
3008
|
success = outcome.success;
|
|
3009
|
+
const TSTRING responseSummary =
|
|
3010
|
+
httpCode > 0 ? gateway::summarizeResponseBody(responseHead) : TSTRING();
|
|
3011
|
+
uint32_t retryAfterMs = 0;
|
|
3012
|
+
if (outcome.retryable && httpCode > 0) {
|
|
3013
|
+
retryAfterMs = gateway::parseRetryAfterMs(http.header("Retry-After"));
|
|
3014
|
+
}
|
|
2989
3015
|
|
|
2990
3016
|
if (!outcome.transportError) {
|
|
2991
|
-
char errorBuf[
|
|
3017
|
+
char errorBuf[320];
|
|
2992
3018
|
const char* reason = outcome.reason.c_str();
|
|
2993
3019
|
const char* sep = outcome.reason.length() > 0 ? ": " : "";
|
|
2994
3020
|
if (success) {
|
|
2995
3021
|
Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
|
|
2996
|
-
} else if (outcome.refusedByBody) {
|
|
2997
|
-
// Success-class status, refusing body. Not retryable: the
|
|
2998
|
-
// service answered, and the origin node gets its words.
|
|
2999
|
-
snprintf(errorBuf, sizeof(errorBuf),
|
|
3000
|
-
"HTTP %d: service refused the request%s%s", httpCode,
|
|
3001
|
-
sep, reason);
|
|
3002
|
-
error = TSTRING(errorBuf);
|
|
3003
|
-
Log(ERROR, "HTTP request refused by the service: %s\n", errorBuf);
|
|
3004
3022
|
} else if (outcome.unverifiedStatus) {
|
|
3005
|
-
// A 2xx outside 200/201/202/204
|
|
3006
|
-
//
|
|
3007
|
-
//
|
|
3008
|
-
//
|
|
3009
|
-
// ERROR level, where a sketch running the default levels
|
|
3010
|
-
// sees it. 203 stays retryable; the rest are final.
|
|
3023
|
+
// A 2xx outside 200/201/202/204: the server answered, but not
|
|
3024
|
+
// with a status this library can call a delivery. Never
|
|
3025
|
+
// retried. Logged at ERROR, where a sketch on the default
|
|
3026
|
+
// levels sees what the service said.
|
|
3011
3027
|
snprintf(errorBuf, sizeof(errorBuf),
|
|
3012
3028
|
"HTTP %d: not a delivery the gateway can confirm%s%s",
|
|
3013
3029
|
httpCode, sep, reason);
|
|
3014
3030
|
error = TSTRING(errorBuf);
|
|
3015
3031
|
Log(ERROR, "HTTP response unverified: %s\n", errorBuf);
|
|
3016
|
-
} else if (
|
|
3017
|
-
//
|
|
3032
|
+
} else if (outcome.retryable) {
|
|
3033
|
+
// 429 or 503: the server says it did not take the request.
|
|
3018
3034
|
snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
|
|
3019
3035
|
reason);
|
|
3020
3036
|
error = TSTRING(errorBuf);
|
|
3021
|
-
Log(COMMUNICATION, "HTTP server
|
|
3022
|
-
|
|
3023
|
-
// HTTP 429 rate limit is retryable, log at COMMUNICATION level
|
|
3024
|
-
snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
|
|
3025
|
-
reason);
|
|
3026
|
-
error = TSTRING(errorBuf);
|
|
3027
|
-
Log(COMMUNICATION, "HTTP rate limit: code=%d (will retry)\n", httpCode);
|
|
3037
|
+
Log(COMMUNICATION, "HTTP %d: the server asked to retry (after %lu ms)\n",
|
|
3038
|
+
httpCode, static_cast<unsigned long>(retryAfterMs));
|
|
3028
3039
|
} else {
|
|
3029
|
-
// 1xx, 3xx, 4xx (except 429) - non-retryable, log at ERROR level
|
|
3030
3040
|
snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
|
|
3031
3041
|
reason);
|
|
3032
3042
|
error = TSTRING(errorBuf);
|
|
3033
3043
|
Log(ERROR, "HTTP request failed: %s\n", errorBuf);
|
|
3034
3044
|
}
|
|
3035
3045
|
} else {
|
|
3036
|
-
//
|
|
3037
|
-
//
|
|
3046
|
+
// Below HTTP. When the request may already be at the server -- a
|
|
3047
|
+
// read timeout, a connection lost after sending -- it is not
|
|
3048
|
+
// retried, and the error says why, so an application that
|
|
3049
|
+
// resends does so knowing it may duplicate.
|
|
3038
3050
|
error = http.errorToString(httpCode);
|
|
3051
|
+
if (!outcome.retryable) {
|
|
3052
|
+
error += " (the request may have reached the server; not retried)";
|
|
3053
|
+
}
|
|
3039
3054
|
Log(ERROR, "HTTP request failed: %s\n", error.c_str());
|
|
3040
3055
|
}
|
|
3041
3056
|
|
|
3042
3057
|
http.end();
|
|
3043
3058
|
|
|
3044
|
-
// Send acknowledgment back
|
|
3045
|
-
//
|
|
3046
|
-
finish(success, outcome.ackStatus, error
|
|
3059
|
+
// Send acknowledgment back, with the verdict on retrying and the
|
|
3060
|
+
// start of whatever the server said.
|
|
3061
|
+
finish(success, outcome.ackStatus, error, outcome.retryable ? 1 : 0,
|
|
3062
|
+
responseSummary, retryAfterMs);
|
|
3047
3063
|
#else
|
|
3048
3064
|
// Non-ESP platform - send error
|
|
3049
|
-
finish(false, 0, "HTTP client not available on this platform");
|
|
3065
|
+
finish(false, 0, "HTTP client not available on this platform", 0);
|
|
3050
3066
|
#endif
|
|
3051
3067
|
|
|
3052
3068
|
return true; // Consume package - we have processed it and sent
|
package/src/painlessMeshSTA.cpp
CHANGED
|
@@ -628,6 +628,9 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
|
|
|
628
628
|
#endif
|
|
629
629
|
bool isRooted = layout::isRooted(mesh->asNodeTree());
|
|
630
630
|
if (isRooted) everRooted = true;
|
|
631
|
+
// The immediate-rescan allowance is per outage, and this is the one place
|
|
632
|
+
// every scan passes through: a node with a connection has not spent it.
|
|
633
|
+
if (painlessmesh::layout::liveSubs(*mesh) > 0) rejoinScanned = false;
|
|
631
634
|
if (aps.empty()) {
|
|
632
635
|
// No unknown nodes found
|
|
633
636
|
consecutiveEmptyScans++;
|
|
@@ -732,11 +735,42 @@ void ICACHE_FLASH_ATTR StationScan::connectToAP() {
|
|
|
732
735
|
task.delay(interval);
|
|
733
736
|
if (orphanScanBackoff < 2) orphanScanBackoff++;
|
|
734
737
|
} else {
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
738
|
+
// Not connected, and this scan turned up nothing to connect to.
|
|
739
|
+
//
|
|
740
|
+
// "Fast" here is 0.5 * SCAN_INTERVAL -- fifteen seconds. For a node
|
|
741
|
+
// that still has a live connection (an AP-side child) that is soon
|
|
742
|
+
// enough: it is in the mesh and reachable while it looks for more.
|
|
743
|
+
// For a node with none it is the whole cost of the outage, and it is
|
|
744
|
+
// paid while the node's neighbours still have it in their routing
|
|
745
|
+
// tables: they keep sending to it until their own NODE_TIMEOUT, and
|
|
746
|
+
// those messages are dropped.
|
|
747
|
+
//
|
|
748
|
+
// Measured on the Alteriom HIL rig (issue #459): an ESP32-C3 closed
|
|
749
|
+
// its only uplink on a momentary nodeSync contradiction, logged
|
|
750
|
+
// "scan rate set to fast" 12 ms later, and did not scan again for
|
|
751
|
+
// exactly 15 000 ms. It was out of the mesh for 16.2 s and answered
|
|
752
|
+
// an empty node list throughout; a unicast sent to it in that window
|
|
753
|
+
// was accepted by the sender and lost. The scan that finally ran
|
|
754
|
+
// found five mesh APs at -30 to -58 dBm and was associated 1.2 s
|
|
755
|
+
// later. There was nothing to wait for.
|
|
756
|
+
//
|
|
757
|
+
// So a node with no connections left scans at once. Once per outage:
|
|
758
|
+
// `rejoinScanned` is cleared as soon as it has a connection again,
|
|
759
|
+
// so a node that is simply alone falls back to the interval instead
|
|
760
|
+
// of scanning back to back.
|
|
739
761
|
task.setInterval(0.5 * SCAN_INTERVAL);
|
|
762
|
+
if (painlessmesh::layout::liveSubs(*mesh) == 0 && !rejoinScanned) {
|
|
763
|
+
rejoinScanned = true;
|
|
764
|
+
Log(CONNECTION,
|
|
765
|
+
"connectToAP(): No connections left; scanning again now rather "
|
|
766
|
+
"than in %d s\n",
|
|
767
|
+
(int)(0.5 * SCAN_INTERVAL / TASK_SECOND));
|
|
768
|
+
task.forceNextIteration();
|
|
769
|
+
} else {
|
|
770
|
+
Log(CONNECTION,
|
|
771
|
+
"connectToAP(): No unknown nodes found scan rate set to "
|
|
772
|
+
"fast\n");
|
|
773
|
+
}
|
|
740
774
|
}
|
|
741
775
|
mesh->stability += min(1000 - mesh->stability, (size_t)25);
|
|
742
776
|
} else {
|