@alteriom/painlessmesh 2.0.2 → 2.0.3

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.
@@ -568,6 +568,8 @@ class InternetHealthChecker {
568
568
  * @return true if connection succeeded
569
569
  */
570
570
  bool checkNow() {
571
+ // A full probe answers the question the on-demand budget exists for.
572
+ onDemandSpent_ = false;
571
573
  status_.checkCount++;
572
574
  status_.lastCheckTime = millis();
573
575
 
@@ -592,6 +594,27 @@ class InternetHealthChecker {
592
594
  return connected;
593
595
  }
594
596
 
597
+ /**
598
+ * @brief Probe once outside the schedule, for a caller that needs the
599
+ * answer now (issue #450)
600
+ *
601
+ * A bridge's first periodic probe runs while its station is still
602
+ * associating and fails, and the next is a full interval away. A send in
603
+ * that window may spend one extra probe to learn that the uplink has come
604
+ * up since. One: the probe is a blocking connect with a timeout, so the
605
+ * budget is a single on-demand probe per periodic one. A node whose uplink
606
+ * really is down pays it once per interval, not on every call.
607
+ *
608
+ * @return true if Internet is reachable now
609
+ */
610
+ bool checkOnDemand() {
611
+ if (status_.available) return true;
612
+ if (onDemandSpent_) return false;
613
+ const bool connected = checkNow();
614
+ onDemandSpent_ = true;
615
+ return connected;
616
+ }
617
+
595
618
  /**
596
619
  * @brief Get check interval
597
620
  * @return Check interval in milliseconds
@@ -667,6 +690,13 @@ class InternetHealthChecker {
667
690
  status_.lastError = "Mock: No Internet in test environment";
668
691
  return false;
669
692
  #elif defined(ESP32) || defined(ESP8266)
693
+ // No station, no uplink: answer at once instead of spending the connect
694
+ // timeout on a link that does not exist. This is what keeps the
695
+ // on-demand probe cheap on a bridge whose router is down.
696
+ if (WiFi.status() != WL_CONNECTED) {
697
+ status_.lastError = "Station not connected";
698
+ return false;
699
+ }
670
700
  WiFiClient client;
671
701
  uint32_t started = millis();
672
702
  #ifdef ESP32
@@ -704,6 +734,7 @@ class InternetHealthChecker {
704
734
  // State
705
735
  InternetStatus status_;
706
736
  InternetChangedCallback_t connectivityChangedCallback_;
737
+ bool onDemandSpent_ = false;
707
738
 
708
739
  #ifdef PAINLESSMESH_BOOST
709
740
  bool mockConnected_ = false;
@@ -988,19 +1019,126 @@ struct HttpRequestOutcome {
988
1019
 
989
1020
  /** True when the client failed before any HTTP status was received. */
990
1021
  bool transportError = false;
1022
+
1023
+ /**
1024
+ * True when the status was success-class but the response body said the
1025
+ * service refused the request (issue #450).
1026
+ */
1027
+ bool refusedByBody = false;
1028
+
1029
+ /**
1030
+ * True for a 2xx the gateway cannot vouch for: anything but 200, 201, 202
1031
+ * and 204. CallMeBot answers HTTP 208 to a message that never arrives
1032
+ * (issue #452), so a status outside the verified set is reported as a
1033
+ * failure that carries the body, never as a delivery.
1034
+ */
1035
+ bool unverifiedStatus = false;
1036
+
1037
+ /**
1038
+ * One-line reason for a failure, taken from the response body when there
1039
+ * is one; empty on success. Sized to fit a GatewayAckPackage::error.
1040
+ */
1041
+ TSTRING reason;
991
1042
  };
992
1043
 
1044
+ /** Longest response-body excerpt carried in an acknowledgment error. */
1045
+ static const size_t GATEWAY_RESPONSE_REASON_MAX = 120;
1046
+
1047
+ /**
1048
+ * How much of a response body the gateway reads for classification and for
1049
+ * the error it reports. Enough for a service's one-line verdict, small enough
1050
+ * that an HTML error page cannot eat an ESP8266's heap.
1051
+ */
1052
+ static const size_t GATEWAY_RESPONSE_HEAD_BYTES = 512;
1053
+
1054
+ /** How long the gateway waits for that head to arrive after the status. */
1055
+ static const uint32_t GATEWAY_RESPONSE_HEAD_TIMEOUT_MS = 250;
1056
+
1057
+ inline bool responseContains(const TSTRING& haystack, const char* needle) {
1058
+ #if defined(PAINLESSMESH_BOOST)
1059
+ return haystack.find(needle) != std::string::npos;
1060
+ #else
1061
+ return haystack.indexOf(needle) >= 0;
1062
+ #endif
1063
+ }
1064
+
1065
+ /**
1066
+ * @brief Reduce a response body to one line fit for a log or an error string
1067
+ *
1068
+ * Tags are dropped, whitespace collapsed and the result cut at maxLen with an
1069
+ * ellipsis, so an HTML error page reads "Oops! Too many requests... You have
1070
+ * called to the API to often." in a serial log instead of markup.
1071
+ */
1072
+ inline TSTRING summarizeResponseBody(
1073
+ const TSTRING& body, size_t maxLen = GATEWAY_RESPONSE_REASON_MAX) {
1074
+ TSTRING out;
1075
+ bool inTag = false;
1076
+ bool pendingSpace = false;
1077
+ for (size_t i = 0; i < body.length(); ++i) {
1078
+ const char c = body[i];
1079
+ if (c == '<') {
1080
+ inTag = true;
1081
+ continue;
1082
+ }
1083
+ if (c == '>') {
1084
+ inTag = false;
1085
+ pendingSpace = true;
1086
+ continue;
1087
+ }
1088
+ if (inTag) continue;
1089
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
1090
+ pendingSpace = true;
1091
+ continue;
1092
+ }
1093
+ if (pendingSpace && out.length() > 0) out += ' ';
1094
+ pendingSpace = false;
1095
+ out += c;
1096
+ if (out.length() >= maxLen) {
1097
+ out += "...";
1098
+ break;
1099
+ }
1100
+ }
1101
+ return out;
1102
+ }
1103
+
1104
+ /**
1105
+ * @brief Does a success-class response body say the service refused the request?
1106
+ *
1107
+ * Some services answer a refusal with a 2xx. CallMeBot's WhatsApp API, which
1108
+ * the sendToInternet example integrates, returns its "Too many requests" page
1109
+ * under HTTP 201 and HTTP 203 (issue #450). The phrases are the ones seen
1110
+ * from services this library's examples target, and they are deliberately
1111
+ * specific: a JSON payload that merely contains the word "error" is not a
1112
+ * refusal.
1113
+ */
1114
+ inline bool responseBodyRefuses(const TSTRING& body) {
1115
+ static const char* const REFUSALS[] = {"Too many requests", "Oops!"};
1116
+ for (const char* phrase : REFUSALS) {
1117
+ if (responseContains(body, phrase)) return true;
1118
+ }
1119
+ return false;
1120
+ }
1121
+
993
1122
  /**
994
- * @brief Classify an HTTPClient return value for the gateway acknowledgment
1123
+ * @brief Classify an HTTPClient result for the gateway acknowledgment
995
1124
  *
996
- * Only 200, 201, 202 and 204 count as success. Other 2xx codes (notably 203,
997
- * Non-Authoritative Information) indicate a cached or proxied response rather
998
- * than delivery to the destination service, so they are failures.
1125
+ * A transport error (rawCode <= 0) is neither success nor an HTTP status.
1126
+ * Only 200, 201, 202 and 204 count as delivery on the status alone, and even
1127
+ * those are overturned by a body that says the service refused the request:
1128
+ * CallMeBot answers "Too many requests" under 201 and 203 (issue #450). Any
1129
+ * other 2xx is unverified. 203 means a proxy transformed the reply; 208 is
1130
+ * what CallMeBot answers to a message that never arrives (issue #452); none
1131
+ * of them is a delivery this library can vouch for, so they are failures
1132
+ * that carry the body. Every failure's reason is a one-line excerpt of the
1133
+ * body when there is one, so the origin node learns why and not only a
1134
+ * number.
999
1135
  *
1000
1136
  * @param rawCode The int returned by HTTPClient::GET() or ::POST()
1137
+ * @param body The start of the response body, empty if none was read
1001
1138
  * @return Classified outcome, safe to place in a GatewayAckPackage
1002
1139
  */
1003
- inline HttpRequestOutcome classifyHttpResult(int rawCode) {
1140
+ inline HttpRequestOutcome classifyHttpResult(int rawCode,
1141
+ const TSTRING& body = TSTRING()) {
1004
1142
  HttpRequestOutcome outcome;
1005
1143
  if (rawCode <= 0) {
1006
1144
  outcome.transportError = true;
@@ -1008,11 +1146,151 @@ inline HttpRequestOutcome classifyHttpResult(int rawCode) {
1008
1146
  }
1009
1147
  outcome.ackStatus =
1010
1148
  static_cast<uint16_t>(rawCode > 0xFFFF ? 0xFFFF : rawCode);
1011
- outcome.success = (rawCode == 200 || rawCode == 201 || rawCode == 202 ||
1012
- rawCode == 204);
1149
+ const bool verified =
1150
+ rawCode == 200 || rawCode == 201 || rawCode == 202 || rawCode == 204;
1151
+ if (verified && responseBodyRefuses(body)) {
1152
+ outcome.refusedByBody = true;
1153
+ outcome.reason = summarizeResponseBody(body);
1154
+ return outcome;
1155
+ }
1156
+ outcome.success = verified;
1157
+ if (!verified) {
1158
+ outcome.unverifiedStatus = rawCode >= 200 && rawCode < 300;
1159
+ outcome.reason = summarizeResponseBody(body);
1160
+ }
1013
1161
  return outcome;
1014
1162
  }
1015
1163
 
1164
+ /**
1165
+ * The phrase a node puts in a failed GATEWAY_ACK when it received a gateway
1166
+ * request but serves none: a bridge that rebooted, crashed or was reflashed
1167
+ * as a regular node announces nothing, and its peers keep routing Internet
1168
+ * requests to it until its last bridge status ages out. The origin node
1169
+ * recognises the phrase, forgets that node as a gateway, and retries through
1170
+ * the next one. Changing it breaks that recognition across a mixed fleet.
1171
+ */
1172
+ static const char GATEWAY_NOT_A_GATEWAY_PHRASE[] = "is not an Internet gateway";
1173
+
1174
+ /**
1175
+ * How long a destination whose name failed to resolve is refused without
1176
+ * another lookup (issue #453). On ESP32 a DNS lookup has no timeout this
1177
+ * library can set, so a dead name stalls the cooperative scheduler for the
1178
+ * resolver's own patience; once a minute is survivable, once per attempt --
1179
+ * with the origin node retrying and the bridge's own sends adding theirs --
1180
+ * was not. On ESP8266 the core bounds HTTPClient's own lookup by the HTTP
1181
+ * timeout, so the gateway performs no separate lookup there and this cache
1182
+ * is never fed.
1183
+ */
1184
+ static const uint32_t GATEWAY_DNS_NEGATIVE_TTL_MS = 60000;
1185
+
1186
+ /**
1187
+ * @brief The host of an http(s) URL, without scheme, port, path or query
1188
+ *
1189
+ * "https://api.example.com:8443/x?y" -> "api.example.com". Empty when the
1190
+ * URL has no host.
1191
+ */
1192
+ inline TSTRING hostFromUrl(const TSTRING& url) {
1193
+ const char* s = url.c_str();
1194
+ const size_t n = url.length();
1195
+ size_t i = 0;
1196
+ for (size_t k = 0; k + 2 < n; ++k) {
1197
+ if (s[k] == ':' && s[k + 1] == '/' && s[k + 2] == '/') {
1198
+ i = k + 3;
1199
+ break;
1200
+ }
1201
+ }
1202
+ size_t j = i;
1203
+ while (j < n && s[j] != '/' && s[j] != '?' && s[j] != '#' && s[j] != ':') ++j;
1204
+ TSTRING host;
1205
+ for (size_t k = i; k < j; ++k) host += s[k];
1206
+ return host;
1207
+ }
1208
+
1209
+ /** True for a dotted-decimal IPv4 literal, which needs no DNS. */
1210
+ inline bool looksLikeIpLiteral(const TSTRING& host) {
1211
+ if (host.length() == 0) return false;
1212
+ for (size_t i = 0; i < host.length(); ++i) {
1213
+ const char c = host[i];
1214
+ if (!(c >= '0' && c <= '9') && c != '.') return false;
1215
+ }
1216
+ return true;
1217
+ }
1218
+
1219
+ /**
1220
+ * @brief A few hosts that recently failed to resolve, and when
1221
+ *
1222
+ * Small and fixed: a gateway talks to a handful of destinations. A host is
1223
+ * remembered with a TTL; while it is within it, isFailing() says so and the
1224
+ * caller answers the request without a lookup.
1225
+ */
1226
+ class NegativeDnsCache {
1227
+ public:
1228
+ // An enum, not a static const member: the test suite passes it to Catch2 by
1229
+ // reference, which needs a definition C++14 cannot give an in-class
1230
+ // constant without an out-of-line one.
1231
+ enum : size_t { SLOTS = 4 };
1232
+
1233
+ void remember(const TSTRING& host, uint32_t nowMs,
1234
+ uint32_t ttlMs = GATEWAY_DNS_NEGATIVE_TTL_MS) {
1235
+ Entry* slot = find(host);
1236
+ if (slot == nullptr) {
1237
+ slot = &entries_[0];
1238
+ for (size_t i = 0; i < SLOTS; ++i) {
1239
+ if (!entries_[i].used) {
1240
+ slot = &entries_[i];
1241
+ break;
1242
+ }
1243
+ if (static_cast<int32_t>(entries_[i].at - slot->at) < 0) slot = &entries_[i];
1244
+ }
1245
+ }
1246
+ slot->used = true;
1247
+ slot->host = host;
1248
+ slot->at = nowMs;
1249
+ slot->ttl = ttlMs;
1250
+ }
1251
+
1252
+ /** Is `host` inside its negative TTL? `ageMs` receives how long ago it failed. */
1253
+ bool isFailing(const TSTRING& host, uint32_t nowMs, uint32_t* ageMs = nullptr) {
1254
+ Entry* slot = find(host);
1255
+ if (slot == nullptr) return false;
1256
+ const uint32_t age = nowMs - slot->at;
1257
+ if (age >= slot->ttl) {
1258
+ slot->used = false;
1259
+ return false;
1260
+ }
1261
+ if (ageMs != nullptr) *ageMs = age;
1262
+ return true;
1263
+ }
1264
+
1265
+ void forget(const TSTRING& host) {
1266
+ Entry* slot = find(host);
1267
+ if (slot != nullptr) slot->used = false;
1268
+ }
1269
+
1270
+ size_t size() const {
1271
+ size_t n = 0;
1272
+ for (size_t i = 0; i < SLOTS; ++i) n += entries_[i].used ? 1 : 0;
1273
+ return n;
1274
+ }
1275
+
1276
+ private:
1277
+ struct Entry {
1278
+ TSTRING host;
1279
+ uint32_t at = 0;
1280
+ uint32_t ttl = 0;
1281
+ bool used = false;
1282
+ };
1283
+
1284
+ Entry* find(const TSTRING& host) {
1285
+ for (size_t i = 0; i < SLOTS; ++i) {
1286
+ if (entries_[i].used && entries_[i].host == host) return &entries_[i];
1287
+ }
1288
+ return nullptr;
1289
+ }
1290
+
1291
+ Entry entries_[SLOTS];
1292
+ };
1293
+
1016
1294
  /**
1017
1295
  * @brief Gateway Acknowledgment Package for delivery confirmations
1018
1296
  *
@@ -1262,13 +1540,15 @@ class GatewayAckPackage : public plugin::SinglePackage {
1262
1540
  * GATEWAY_DNS_TIMEOUT_MS on ESP8266, skipped on ESP32) runs before them
1263
1541
  * whenever the GATEWAY_CONNECTIVITY_CACHE_MS window has expired.
1264
1542
  *
1265
- * @warning One residual is not in this budget: the HTTP calls resolve their
1266
- * hostnames inside the platform core before their socket timeout
1267
- * applies, and on ESP32 that in-request resolver wait is not
1268
- * separately boundable in the cores this library targets. On a
1269
- * network with blackholed DNS the request path can therefore still
1270
- * exceed this ceiling on ESP32. See SECURITY.md "Gateway blocking:
1271
- * the mesh partition risk".
1543
+ * @warning One residual is not in this budget: hostname resolution on ESP32
1544
+ * is not separately boundable in the cores this library targets.
1545
+ * Since issue #453 the handler performs that lookup itself on
1546
+ * ESP32 and remembers a failure for GATEWAY_DNS_NEGATIVE_TTL_MS, so
1547
+ * on a network with blackholed DNS the request path can still
1548
+ * exceed this ceiling on ESP32, but at most once per TTL per
1549
+ * destination host rather than once per attempt. On ESP8266 the
1550
+ * core bounds HTTPClient's own lookup by the HTTP timeout. See
1551
+ * SECURITY.md "Gateway blocking: the mesh partition risk".
1272
1552
  */
1273
1553
  constexpr unsigned long gatewayBlockingBudgetMs() {
1274
1554
  return 2UL * static_cast<unsigned long>(GATEWAY_HTTP_TIMEOUT_MS) +
@@ -30,6 +30,19 @@ typedef enum {
30
30
  DEBUG = 1 << 11
31
31
  } LogLevel;
32
32
 
33
+ // Log() is printf-like. In the desktop test build the compiler is told so,
34
+ // and -Wall -Werror in CI then rejects any argument that does not match its
35
+ // specifier. That class of bug reached main twice in routePackage(): size_t
36
+ // through %d/%u, and a DeserializationError object through %u (CodeQL
37
+ // cpp/wrong-type-format-argument). The check is kept to the desktop build on
38
+ // purpose: ESP-IDF 5 makes uint32_t `unsigned long`, so on ESP32 every %u of
39
+ // a node id would warn, harmlessly, in every user's build.
40
+ #if defined(PAINLESSMESH_BOOST) && defined(__GNUC__)
41
+ #define PAINLESSMESH_LOG_FORMAT __attribute__((format(printf, 3, 4)))
42
+ #else
43
+ #define PAINLESSMESH_LOG_FORMAT
44
+ #endif
45
+
33
46
  class LogClass {
34
47
  public:
35
48
  // Where messages go instead of Serial. The sketch owns framing: on a board
@@ -83,7 +96,7 @@ class LogClass {
83
96
  Serial.println();
84
97
  return;
85
98
  }
86
- void operator()(LogLevel type, const char *format...) {
99
+ void operator()(LogLevel type, const char *format...) PAINLESSMESH_LOG_FORMAT {
87
100
  if (type & types) { // Print only the message types set for output
88
101
  va_list args;
89
102
  va_start(args, format);
@@ -98,6 +98,9 @@ struct PendingInternetRequest {
98
98
  TSTRING destination = ""; ///< Internet destination URL
99
99
  TSTRING payload = ""; ///< Request payload
100
100
  internetResultCallback_t callback; ///< User callback for result
101
+ /// Why the last attempt failed, when retries were spent waiting for a
102
+ /// gateway; the final callback reports it instead of a bare retry count.
103
+ TSTRING lastError = "";
101
104
 
102
105
  /**
103
106
  * Check if this request has timed out
@@ -243,6 +246,37 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
243
246
  painlessmesh::ntp::addPackageCallback(this->callbackList, (*this));
244
247
  painlessmesh::router::addPackageCallback(this->callbackList, (*this));
245
248
 
249
+ // Every node answers a gateway request it cannot serve. A bridge that
250
+ // reboots, crashes or is reflashed as a regular node announces nothing
251
+ // -- only a bridge stepping down in-process sends `leaving` -- so its
252
+ // peers keep it in knownBridges until its last status ages out
253
+ // (bridgeTimeoutMs, 60 s) and may route every Internet request to it.
254
+ // A regular node had no GATEWAY_DATA handler and dropped them without a
255
+ // word; the origin waited out its request timeout, 30 s, each time.
256
+ // Found on the HIL rig while validating 2.0.3.
257
+ //
258
+ // Registered first, so a gateway's own handler (initGatewayInternetHandler
259
+ // in wifi.hpp, or a test's stand-in) is always the second: this one
260
+ // stays silent whenever any other GATEWAY_DATA handler exists.
261
+ this->callbackList.onPackage(
262
+ protocol::GATEWAY_DATA,
263
+ [this](protocol::Variant& variant, std::shared_ptr<T> ingress,
264
+ uint32_t) {
265
+ if (this->callbackList.count(protocol::GATEWAY_DATA) > 1) {
266
+ return false; // this node is a gateway; its handler answers
267
+ }
268
+ auto pkg = variant.to<gateway::GatewayDataPackage>();
269
+ char errorBuf[96];
270
+ snprintf(errorBuf, sizeof(errorBuf), "Node %u %s",
271
+ static_cast<unsigned>(this->nodeId),
272
+ gateway::GATEWAY_NOT_A_GATEWAY_PHRASE);
273
+ Log(logger::ERROR,
274
+ "GATEWAY_DATA from node %u answered: %s\n",
275
+ static_cast<unsigned>(pkg.originNode), errorBuf);
276
+ this->sendGatewayAck(pkg, false, 0, TSTRING(errorBuf), ingress);
277
+ return true;
278
+ });
279
+
246
280
  // Seed delivery-confirmation ids with a random value so they do not
247
281
  // restart at 1 after a reboot — a delayed ACK for a pre-reboot
248
282
  // message could otherwise match a fresh message's id and report a
@@ -1349,7 +1383,8 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1349
1383
 
1350
1384
  size_t removed = sizeBefore - knownBridges.size();
1351
1385
  if (removed > 0) {
1352
- Log(GENERAL, "cleanupExpiredBridges(): Removed %u expired bridges\n", removed);
1386
+ Log(GENERAL, "cleanupExpiredBridges(): Removed %lu expired bridges\n",
1387
+ static_cast<unsigned long>(removed));
1353
1388
  }
1354
1389
  }
1355
1390
 
@@ -1432,6 +1467,31 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1432
1467
  return internetHealthChecker.hasLocalInternet();
1433
1468
  }
1434
1469
 
1470
+ /**
1471
+ * Can this node serve a gateway request on its own uplink right now?
1472
+ *
1473
+ * hasLocalInternet() is the last periodic probe's answer. On a bridge the
1474
+ * first probe runs while the station is still associating and fails, and
1475
+ * the next is a full interval (30 s) away, so for that whole window a send
1476
+ * fell through to the mesh path and was refused for want of a peer (issue
1477
+ * #450). When the flag says no and the health check is running, spend the
1478
+ * one on-demand probe the checker allows per period: a send made after the
1479
+ * uplink came up is then served locally, and a node whose uplink really is
1480
+ * down pays one probe per interval, not one per call.
1481
+ *
1482
+ * @return true if the request should go to this node's own gateway handler
1483
+ */
1484
+ bool uplinkServesLocally() {
1485
+ if (hasLocalInternet()) return true;
1486
+ if (!isInternetHealthCheckEnabled()) return false;
1487
+ if (internetHealthChecker.checkOnDemand()) {
1488
+ Log(logger::COMMUNICATION,
1489
+ "uplinkServesLocally(): on-demand probe found the uplink up\n");
1490
+ return true;
1491
+ }
1492
+ return false;
1493
+ }
1494
+
1435
1495
  /**
1436
1496
  * Register callback for local Internet connectivity changes
1437
1497
  *
@@ -1672,7 +1732,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1672
1732
  // A shared gateway can execute its own gateway package locally. Keep the
1673
1733
  // same package handler, pending-request tracking and callback semantics,
1674
1734
  // but do not require a mesh peer merely to reach this node's own uplink.
1675
- if (hasLocalInternet()) {
1735
+ if (uplinkServesLocally()) {
1676
1736
  PendingInternetRequest request;
1677
1737
  request.messageId = messageId;
1678
1738
  request.timestamp = millis();
@@ -1935,6 +1995,65 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1935
1995
  /**
1936
1996
  * Handle incoming gateway acknowledgment package
1937
1997
  */
1998
+ public:
1999
+ // Public: the gateway handler in wifi.hpp, a derived class, answers with it.
2000
+ /**
2001
+ * Answer a gateway request: send its GATEWAY_ACK back to the origin node.
2002
+ *
2003
+ * A request this node originated completes locally, without a mesh hop.
2004
+ * Otherwise the ack is routed to the origin; when the NodeTree has not yet
2005
+ * converged enough for findRoute() to resolve it -- a freshly promoted
2006
+ * gateway can receive data before that -- the connection the request
2007
+ * arrived on is a valid reverse path, since every intermediate node routes
2008
+ * the addressed ack onward toward the origin.
2009
+ *
2010
+ * Used by the gateway's own handler (wifi.hpp) and by the fallback every
2011
+ * node registers for requests it cannot serve.
2012
+ */
2013
+ void sendGatewayAck(const gateway::GatewayDataPackage& request, bool success,
2014
+ uint16_t httpStatus, const TSTRING& error,
2015
+ std::shared_ptr<T> ingressConnection = nullptr) {
2016
+ using namespace logger;
2017
+
2018
+ gateway::GatewayAckPackage ack;
2019
+ ack.from = this->nodeId;
2020
+ ack.dest = request.originNode;
2021
+ ack.messageId = request.messageId;
2022
+ ack.originNode = request.originNode;
2023
+ ack.success = success;
2024
+ ack.httpStatus = httpStatus;
2025
+ ack.error = error;
2026
+ ack.timestamp = this->getNodeTime();
2027
+
2028
+ if (request.originNode == this->nodeId) {
2029
+ protocol::Variant variant(&ack);
2030
+ this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2031
+ Log(COMMUNICATION,
2032
+ "Completed local GATEWAY_ACK (success=%d, http=%d)\n", success,
2033
+ httpStatus);
2034
+ return;
2035
+ }
2036
+
2037
+ auto conn = router::findRoute<T>((*this), request.originNode);
2038
+ if (!conn && ingressConnection) {
2039
+ conn = ingressConnection;
2040
+ Log(COMMUNICATION,
2041
+ "Routing GATEWAY_ACK to node %u through request ingress while "
2042
+ "topology converges\n",
2043
+ request.originNode);
2044
+ }
2045
+ if (conn) {
2046
+ protocol::Variant variant(&ack);
2047
+ router::send(std::move(variant), conn);
2048
+ Log(COMMUNICATION, "Sent GATEWAY_ACK to node %u (success=%d, http=%d)\n",
2049
+ request.originNode, success, httpStatus);
2050
+ } else {
2051
+ Log(ERROR, "Failed to send GATEWAY_ACK: no route to node %u\n",
2052
+ request.originNode);
2053
+ }
2054
+ }
2055
+
2056
+ private:
1938
2057
  void handleGatewayAck(const gateway::GatewayAckPackage& ack) {
1939
2058
  using namespace logger;
1940
2059
  Log(COMMUNICATION, "handleGatewayAck(): msgId=%u success=%d http=%u\n",
@@ -1958,6 +2077,46 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1958
2077
  return;
1959
2078
  }
1960
2079
 
2080
+ // The node this request went to is not a gateway (issue found on the
2081
+ // HIL rig while validating 2.0.3): it rebooted or was reflashed as a
2082
+ // regular node without announcing it, and its last bridge status is
2083
+ // still inside bridgeTimeoutMs. Forget it and send again through the
2084
+ // next gateway at once. The retry budget is not spent: nothing was
2085
+ // attempted against the Internet, and every such answer removes one
2086
+ // entry from knownBridges, so this cannot loop.
2087
+ if (gateway::responseContains(ack.error,
2088
+ gateway::GATEWAY_NOT_A_GATEWAY_PHRASE)) {
2089
+ const uint32_t staleGateway = request.gatewayNodeId;
2090
+ bool forgot = this->forgetBridge(staleGateway);
2091
+ if (ack.from != staleGateway) forgot = this->forgetBridge(ack.from) || forgot;
2092
+ Log(ERROR, "handleGatewayAck(): %s; forgot it as a gateway for msgId=%u\n",
2093
+ ack.error.c_str(), ack.messageId);
2094
+
2095
+ if (!forgot) {
2096
+ // Nothing to forget, so a redispatch would reach the same node.
2097
+ // Fall through to the ordinary failure path below.
2098
+ } else if (this->getPrimaryBridge() == nullptr &&
2099
+ !this->uplinkServesLocally()) {
2100
+ // No gateway left right now. On the rig the live bridge's first
2101
+ // status arrived half a second after the ex-bridge answered, so an
2102
+ // accepted request rides the ordinary retry backoff rather than
2103
+ // failing on the spot; retryInternetRequest() reschedules while no
2104
+ // gateway is known, and the final callback carries this reason.
2105
+ char errorBuf[160];
2106
+ snprintf(errorBuf, sizeof(errorBuf),
2107
+ "No Internet gateway available: %s", ack.error.c_str());
2108
+ request.lastError = TSTRING(errorBuf);
2109
+ scheduleInternetRetry(ack.messageId);
2110
+ return;
2111
+ } else {
2112
+ const uint32_t messageId = ack.messageId;
2113
+ this->addTask([this, messageId]() {
2114
+ this->retryInternetRequest(messageId);
2115
+ });
2116
+ return;
2117
+ }
2118
+ }
2119
+
1961
2120
  // Failure response - determine if retryable
1962
2121
  bool isRetryable = false;
1963
2122
 
@@ -2081,11 +2240,14 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2081
2240
  PendingInternetRequest& request = it->second;
2082
2241
 
2083
2242
  if (request.retryCount >= request.maxRetries) {
2084
- // Max retries reached - fail the request
2243
+ // Max retries reached - fail the request, with the reason the retries
2244
+ // were spent on when there is one
2085
2245
  Log(logger::ERROR, "scheduleInternetRetry(): Max retries reached for msgId=%u\n",
2086
2246
  messageId);
2087
2247
  if (request.callback) {
2088
- request.callback(false, 0, "Max retries exceeded");
2248
+ request.callback(false, 0,
2249
+ request.lastError.length() > 0 ? request.lastError
2250
+ : TSTRING("Max retries exceeded"));
2089
2251
  }
2090
2252
  pendingInternetRequests.erase(it);
2091
2253
  return;
@@ -2124,7 +2286,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2124
2286
  //
2125
2287
  // Copy what the redispatch needs first: the local handler runs
2126
2288
  // synchronously and can erase this entry, leaving `request` dangling.
2127
- if (request.gatewayNodeId == this->nodeId && hasLocalInternet()) {
2289
+ if (request.gatewayNodeId == this->nodeId && uplinkServesLocally()) {
2128
2290
  const uint8_t priority = request.priority;
2129
2291
  const uint8_t retryCount = request.retryCount;
2130
2292
  const TSTRING destination = request.destination;
@@ -204,9 +204,14 @@ void routePackage(const layout::Layout<T>& layout,
204
204
  #if ARDUINOJSON_VERSION_MAJOR == 7
205
205
  protocol::Variant variant(pkg);
206
206
  if (variant.error) {
207
+ // The error is a DeserializationError object, not an integer: passing it
208
+ // through varargs is undefined behaviour. size_t is unsigned long on the
209
+ // 64-bit desktop build and unsigned int on ESP32/ESP8266, so it is cast
210
+ // to one type and printed with the matching specifier.
207
211
  Log(ERROR,
208
- "routePackage(): parsing failed. err=%u, total_length=%d, data=%s<--\n",
209
- variant.error, pkg.length(), pkg.c_str());
212
+ "routePackage(): parsing failed. err=%s, total_length=%lu, data=%s<--\n",
213
+ variant.error.c_str(), static_cast<unsigned long>(pkg.length()),
214
+ pkg.c_str());
210
215
  return;
211
216
  }
212
217
 
@@ -248,15 +253,20 @@ void routePackage(const layout::Layout<T>& layout,
248
253
  auto variant = std::make_shared<protocol::Variant>(pkg, capacity);
249
254
 
250
255
  if (variant->error) {
256
+ // Same two defects as the ArduinoJson 7 branch above (CodeQL
257
+ // cpp/wrong-type-format-argument, alerts #6-#9).
251
258
  if (variant->error == DeserializationError::NoMemory) {
252
259
  Log(ERROR,
253
- "routePackage(): Message too large. length=%d, calculated_capacity=%u, "
254
- "nesting_depth=%u. Consider increasing MAX_MESSAGE_CAPACITY if needed.\n",
255
- pkg.length(), calculatedCapacity, nestingDepth);
260
+ "routePackage(): Message too large. length=%lu, calculated_capacity=%lu, "
261
+ "nesting_depth=%lu. Consider increasing MAX_MESSAGE_CAPACITY if needed.\n",
262
+ static_cast<unsigned long>(pkg.length()),
263
+ static_cast<unsigned long>(calculatedCapacity),
264
+ static_cast<unsigned long>(nestingDepth));
256
265
  } else {
257
266
  Log(ERROR,
258
- "routePackage(): parsing failed. err=%u, length=%d, data=%s<--\n",
259
- variant->error, pkg.length(), pkg.c_str());
267
+ "routePackage(): parsing failed. err=%s, length=%lu, data=%s<--\n",
268
+ variant->error.c_str(), static_cast<unsigned long>(pkg.length()),
269
+ pkg.c_str());
260
270
  }
261
271
  return;
262
272
  }