@alteriom/painlessmesh 2.0.2 → 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.
@@ -39,6 +39,7 @@
39
39
  #endif
40
40
  #include "painlessmesh/plugin.hpp"
41
41
  #include "painlessmesh/protocol.hpp"
42
+ #include "painlessmesh/validation.hpp"
42
43
 
43
44
  #include <functional>
44
45
  #include <map>
@@ -568,6 +569,8 @@ class InternetHealthChecker {
568
569
  * @return true if connection succeeded
569
570
  */
570
571
  bool checkNow() {
572
+ // A full probe answers the question the on-demand budget exists for.
573
+ onDemandSpent_ = false;
571
574
  status_.checkCount++;
572
575
  status_.lastCheckTime = millis();
573
576
 
@@ -592,6 +595,27 @@ class InternetHealthChecker {
592
595
  return connected;
593
596
  }
594
597
 
598
+ /**
599
+ * @brief Probe once outside the schedule, for a caller that needs the
600
+ * answer now (issue #450)
601
+ *
602
+ * A bridge's first periodic probe runs while its station is still
603
+ * associating and fails, and the next is a full interval away. A send in
604
+ * that window may spend one extra probe to learn that the uplink has come
605
+ * up since. One: the probe is a blocking connect with a timeout, so the
606
+ * budget is a single on-demand probe per periodic one. A node whose uplink
607
+ * really is down pays it once per interval, not on every call.
608
+ *
609
+ * @return true if Internet is reachable now
610
+ */
611
+ bool checkOnDemand() {
612
+ if (status_.available) return true;
613
+ if (onDemandSpent_) return false;
614
+ const bool connected = checkNow();
615
+ onDemandSpent_ = true;
616
+ return connected;
617
+ }
618
+
595
619
  /**
596
620
  * @brief Get check interval
597
621
  * @return Check interval in milliseconds
@@ -667,6 +691,13 @@ class InternetHealthChecker {
667
691
  status_.lastError = "Mock: No Internet in test environment";
668
692
  return false;
669
693
  #elif defined(ESP32) || defined(ESP8266)
694
+ // No station, no uplink: answer at once instead of spending the connect
695
+ // timeout on a link that does not exist. This is what keeps the
696
+ // on-demand probe cheap on a bridge whose router is down.
697
+ if (WiFi.status() != WL_CONNECTED) {
698
+ status_.lastError = "Station not connected";
699
+ return false;
700
+ }
670
701
  WiFiClient client;
671
702
  uint32_t started = millis();
672
703
  #ifdef ESP32
@@ -704,6 +735,7 @@ class InternetHealthChecker {
704
735
  // State
705
736
  InternetStatus status_;
706
737
  InternetChangedCallback_t connectivityChangedCallback_;
738
+ bool onDemandSpent_ = false;
707
739
 
708
740
  #ifdef PAINLESSMESH_BOOST
709
741
  bool mockConnected_ = false;
@@ -853,13 +885,26 @@ class GatewayDataPackage : public plugin::SinglePackage {
853
885
  */
854
886
  bool requiresAck = false;
855
887
 
888
+ /**
889
+ * @brief Random value drawn once per sendToInternet() call
890
+ *
891
+ * The same on every attempt at that call, and part of its X-Request-Id and
892
+ * Idempotency-Key (requestIdFor()). messageId alone repeats: its counter is
893
+ * 16 bits, so a node that sends more than 65,535 requests in one boot
894
+ * reissues old ids, and a service that remembers keys would drop the new
895
+ * request as a repeat. 0 from a node that predates the field. JSON key
896
+ * "nonce", omitted when 0.
897
+ */
898
+ uint32_t requestNonce = 0;
899
+
856
900
  /**
857
901
  * @brief Number of additional JSON fields in this package
858
902
  *
859
903
  * Used for jsonObjectSize() calculation in ArduinoJson v6.
860
- * Count: msgId, origin, ts, prio, dest_url, payload, content, retry, ack = 9 fields
904
+ * Count: msgId, origin, ts, prio, dest_url, payload, content, retry, ack,
905
+ * nonce = 10 fields
861
906
  */
862
- static constexpr int numPackageFields = 9;
907
+ static constexpr int numPackageFields = 10;
863
908
 
864
909
  /**
865
910
  * @brief Default constructor
@@ -883,6 +928,7 @@ class GatewayDataPackage : public plugin::SinglePackage {
883
928
  priority = jsonObj["prio"];
884
929
  retryCount = jsonObj["retry"];
885
930
  requiresAck = jsonObj["ack"] | false;
931
+ requestNonce = jsonObj["nonce"] | 0UL;
886
932
 
887
933
  #if ARDUINOJSON_VERSION_MAJOR < 7
888
934
  if (jsonObj.containsKey("dest_url"))
@@ -920,6 +966,7 @@ class GatewayDataPackage : public plugin::SinglePackage {
920
966
  jsonObj["content"] = contentType;
921
967
  jsonObj["retry"] = retryCount;
922
968
  jsonObj["ack"] = requiresAck;
969
+ if (requestNonce != 0) jsonObj["nonce"] = requestNonce;
923
970
  return jsonObj;
924
971
  }
925
972
 
@@ -957,30 +1004,89 @@ class GatewayDataPackage : public plugin::SinglePackage {
957
1004
  * @return A unique message ID
958
1005
  */
959
1006
  static uint32_t generateMessageId(uint32_t nodeId) {
960
- static uint16_t counter = 0;
1007
+ // The counter starts at a random point each boot. Starting at zero, the
1008
+ // first request after every reboot carried the same id as the first
1009
+ // request of the boot before -- and so the same X-Request-Id and
1010
+ // Idempotency-Key, which a service that remembers keys drops as a repeat.
1011
+ static uint16_t counter =
1012
+ static_cast<uint16_t>(validation::SecureRandom::generate());
961
1013
  ++counter;
1014
+ if (counter == 0) ++counter;
962
1015
  // Combine node ID (upper 16 bits) with counter (lower 16 bits)
963
1016
  return ((nodeId & 0xFFFF) << 16) | counter;
964
1017
  }
965
1018
 
966
1019
  };
967
1020
 
1021
+ /**
1022
+ * @brief HTTPClient transport errors, by what they say about the request
1023
+ *
1024
+ * HTTPClient::GET()/POST() return a negative code when the request failed
1025
+ * below HTTP. The ESP32 and ESP8266 cores number them the same way. What
1026
+ * matters to a retry is whether the request can have reached the server:
1027
+ * resending one that did delivers it twice, which for a request with an
1028
+ * effect -- a message, a payment, a counter -- is a second effect (the rig
1029
+ * showed a timed-out send issued four times).
1030
+ *
1031
+ * Where each is raised, in both cores' HTTPClient::sendRequest() for the
1032
+ * buffer GET()/POST() the gateway uses: -1, -2 and -3 before the request is
1033
+ * complete; everything else from handleHeaderResponse(), which runs after the
1034
+ * whole request was written -- so -4 is a connection that closed before the
1035
+ * reply began and -7 a reply that was not HTTP, both with the request already
1036
+ * at the server. -6 and -8 come from stream sends and from reading a body.
1037
+ */
1038
+ enum HttpTransportError : int {
1039
+ HTTP_TRANSPORT_CONNECTION_REFUSED = -1, ///< no connection was made
1040
+ HTTP_TRANSPORT_SEND_HEADER_FAILED = -2, ///< the request line never completed
1041
+ HTTP_TRANSPORT_SEND_PAYLOAD_FAILED = -3, ///< the body never completed
1042
+ HTTP_TRANSPORT_NOT_CONNECTED = -4, ///< closed before the reply began; sent
1043
+ HTTP_TRANSPORT_CONNECTION_LOST = -5, ///< dropped while reading the reply; sent
1044
+ HTTP_TRANSPORT_NO_STREAM = -6, ///< no stream to send or read with
1045
+ HTTP_TRANSPORT_NO_HTTP_SERVER = -7, ///< the reply was not HTTP; sent
1046
+ HTTP_TRANSPORT_TOO_LESS_RAM = -8, ///< out of memory sending or reading
1047
+ HTTP_TRANSPORT_ENCODING = -9, ///< the reply arrived malformed; sent
1048
+ HTTP_TRANSPORT_STREAM_WRITE = -10, ///< the reply could not be stored; sent
1049
+ HTTP_TRANSPORT_READ_TIMEOUT = -11, ///< sent, and no reply in time
1050
+ };
1051
+
1052
+ /**
1053
+ * @brief Can a request that failed with this transport error have reached
1054
+ * the server?
1055
+ *
1056
+ * True -- the safe answer -- for every code that is not known to fail before
1057
+ * the request was complete, including codes this library does not know.
1058
+ */
1059
+ inline bool transportErrorMayHaveReachedServer(int rawCode) {
1060
+ switch (rawCode) {
1061
+ case HTTP_TRANSPORT_CONNECTION_REFUSED:
1062
+ case HTTP_TRANSPORT_SEND_HEADER_FAILED:
1063
+ case HTTP_TRANSPORT_SEND_PAYLOAD_FAILED:
1064
+ return false;
1065
+ default:
1066
+ return true;
1067
+ }
1068
+ }
1069
+
968
1070
  /**
969
1071
  * @brief The outcome of a gateway HTTP request, classified for the ack
970
1072
  *
971
1073
  * HTTPClient::GET()/POST() return an int with two distinct meanings: a
972
1074
  * positive value is an HTTP status code from the destination, while a
973
- * negative value is one of the client's own transport errors (HTTPC_ERROR_*,
974
- * e.g. -1 for a refused connection or -11 for a read timeout). Zero is not
975
- * produced by either.
1075
+ * negative value is one of the client's own transport errors (see
1076
+ * HttpTransportError). Zero is not produced by either.
976
1077
  *
977
1078
  * GatewayAckPackage::httpStatus is a uint16_t, so a negative code cannot be
978
- * forwarded as-is. Transport errors are reported as httpStatus 0, which is
979
- * what Mesh::handleGatewayAck() already treats as a retryable network error;
980
- * the human-readable cause travels in GatewayAckPackage::error instead.
1079
+ * forwarded as-is. Transport errors are reported as httpStatus 0 with the
1080
+ * cause in GatewayAckPackage::error, and whether the origin node may resend
1081
+ * the request travels in GatewayAckPackage::retryable.
1082
+ *
1083
+ * The library applies HTTP's meaning of a status and nothing else. Whether a
1084
+ * particular service's reply means what the application wanted -- a message
1085
+ * really queued, a record really written -- is the application's decision,
1086
+ * made from the status and the response the result carries.
981
1087
  */
982
1088
  struct HttpRequestOutcome {
983
- /** True only for status codes that indicate genuine delivery. */
1089
+ /** True only for 200, 201, 202 and 204. */
984
1090
  bool success = false;
985
1091
 
986
1092
  /** Value to place in GatewayAckPackage::httpStatus (0 for transport errors). */
@@ -988,31 +1094,503 @@ struct HttpRequestOutcome {
988
1094
 
989
1095
  /** True when the client failed before any HTTP status was received. */
990
1096
  bool transportError = false;
1097
+
1098
+ /**
1099
+ * True for a 2xx outside 200, 201, 202 and 204. 203 says a proxy
1100
+ * transformed the reply; 205, 206 and 208 are not what a request to an API
1101
+ * endpoint expects. The server answered, so it is never retried; it is
1102
+ * reported as a failure the application can inspect.
1103
+ */
1104
+ bool unverifiedStatus = false;
1105
+
1106
+ /**
1107
+ * Whether resending the identical request is safe and useful: the request
1108
+ * cannot have reached the server, or the server said it did not take it
1109
+ * and to come back (429 Too Many Requests, 503 Service Unavailable). A
1110
+ * reply that may mean the request was processed -- any 2xx, a 500, a
1111
+ * gateway timeout, a read timeout -- is not retried, because a retry would
1112
+ * be a second copy of it.
1113
+ */
1114
+ bool retryable = false;
1115
+
1116
+ /**
1117
+ * One-line summary of the response body, for the error an application
1118
+ * reads; empty on success and when no body was read.
1119
+ */
1120
+ TSTRING reason;
991
1121
  };
992
1122
 
993
1123
  /**
994
- * @brief Classify an HTTPClient return value for the gateway acknowledgment
1124
+ * Longest response-body summary carried in an acknowledgment. Long enough
1125
+ * for a service that repeats the request back before its verdict -- the
1126
+ * reply in issue #463 spent 97 of 120 characters on the echo and was cut
1127
+ * before the part that said why.
1128
+ */
1129
+ static const size_t GATEWAY_RESPONSE_REASON_MAX = 240;
1130
+
1131
+ /**
1132
+ * How much of a response body the gateway keeps for the summary: its first
1133
+ * GATEWAY_RESPONSE_HEAD_BYTES and its last GATEWAY_RESPONSE_TAIL_BYTES.
1134
+ * Enough for a service's verdict at either end, small enough that an HTML
1135
+ * error page cannot eat an ESP8266's heap.
1136
+ */
1137
+ static const size_t GATEWAY_RESPONSE_HEAD_BYTES = 512;
1138
+ static const size_t GATEWAY_RESPONSE_TAIL_BYTES = 256;
1139
+
1140
+ /**
1141
+ * How far into a body the gateway reads looking for its end. Past this the
1142
+ * tail kept is the last of what was read, not the body's end; reading on
1143
+ * would hold the uplink for a reply nobody summarises.
1144
+ */
1145
+ static const size_t GATEWAY_RESPONSE_SCAN_BYTES = 8192;
1146
+
1147
+ /** How long the gateway reads the body for, after the status. */
1148
+ static const uint32_t GATEWAY_RESPONSE_HEAD_TIMEOUT_MS = 250;
1149
+
1150
+ /**
1151
+ * Longest wait a server's Retry-After may impose before a retry. A longer one
1152
+ * is not honoured with a retry at all: the request fails and says when the
1153
+ * server asked to be retried, which is the application's call to make.
1154
+ */
1155
+ static const uint32_t GATEWAY_RETRY_AFTER_MAX_MS = 60000;
1156
+
1157
+ inline bool responseContains(const TSTRING& haystack, const char* needle) {
1158
+ #if defined(PAINLESSMESH_BOOST)
1159
+ return haystack.find(needle) != std::string::npos;
1160
+ #else
1161
+ return haystack.indexOf(needle) >= 0;
1162
+ #endif
1163
+ }
1164
+
1165
+ /**
1166
+ * @brief Reduce a response body to one line fit for a log or an error string
995
1167
  *
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.
1168
+ * Tags are dropped and whitespace collapsed, so an HTML page reads as text in
1169
+ * a serial log. A body longer than maxLen keeps its beginning and its end,
1170
+ * joined by " ... ": services often lead with an echo of the request and
1171
+ * finish with the verdict, and a summary that keeps only the beginning keeps
1172
+ * the echo and loses the verdict (issue #463).
1173
+ */
1174
+ inline TSTRING summarizeResponseBody(
1175
+ const TSTRING& body, size_t maxLen = GATEWAY_RESPONSE_REASON_MAX) {
1176
+ TSTRING text;
1177
+ bool inTag = false;
1178
+ bool pendingSpace = false;
1179
+ for (size_t i = 0; i < body.length(); ++i) {
1180
+ const char c = body[i];
1181
+ if (c == '<') {
1182
+ inTag = true;
1183
+ continue;
1184
+ }
1185
+ if (c == '>') {
1186
+ inTag = false;
1187
+ pendingSpace = true;
1188
+ continue;
1189
+ }
1190
+ if (inTag) continue;
1191
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
1192
+ pendingSpace = true;
1193
+ continue;
1194
+ }
1195
+ if (pendingSpace && text.length() > 0) text += ' ';
1196
+ pendingSpace = false;
1197
+ text += c;
1198
+ }
1199
+ if (text.length() <= maxLen) return text;
1200
+
1201
+ static const char JOIN[] = " ... ";
1202
+ const size_t joinLength = sizeof(JOIN) - 1;
1203
+ const size_t room = maxLen > joinLength ? maxLen - joinLength : 0;
1204
+ const size_t head = room / 2;
1205
+ const size_t tail = room - head;
1206
+ TSTRING out;
1207
+ for (size_t i = 0; i < head; ++i) out += text[i];
1208
+ out += JOIN;
1209
+ for (size_t i = text.length() - tail; i < text.length(); ++i) out += text[i];
1210
+ return out;
1211
+ }
1212
+
1213
+ /**
1214
+ * @brief The start and the end of a response body, read a byte at a time
1215
+ *
1216
+ * The gateway cannot keep a whole body, and a service may put its verdict at
1217
+ * either end: CallMeBot echoes the request first and says why last (#463), so
1218
+ * a reply of a kilobyte kept as its first 512 bytes had lost the part that
1219
+ * mattered before any summary ran. This keeps the first
1220
+ * GATEWAY_RESPONSE_HEAD_BYTES and a rolling last GATEWAY_RESPONSE_TAIL_BYTES,
1221
+ * and text() joins them with " ... " when bytes were dropped between.
1222
+ */
1223
+ class ResponseExcerpt {
1224
+ public:
1225
+ /** Keep one more byte; false once GATEWAY_RESPONSE_SCAN_BYTES were seen. */
1226
+ bool add(char c) {
1227
+ ++seen_;
1228
+ if (head_.length() < GATEWAY_RESPONSE_HEAD_BYTES) {
1229
+ head_ += c;
1230
+ } else {
1231
+ tail_[(tailStart_ + tailLength_) % GATEWAY_RESPONSE_TAIL_BYTES] = c;
1232
+ if (tailLength_ < GATEWAY_RESPONSE_TAIL_BYTES) {
1233
+ ++tailLength_;
1234
+ } else {
1235
+ tailStart_ = (tailStart_ + 1) % GATEWAY_RESPONSE_TAIL_BYTES;
1236
+ }
1237
+ }
1238
+ return seen_ < GATEWAY_RESPONSE_SCAN_BYTES;
1239
+ }
1240
+
1241
+ void add(const TSTRING& text) {
1242
+ for (size_t i = 0; i < text.length(); ++i) {
1243
+ if (!add(text[i])) return;
1244
+ }
1245
+ }
1246
+
1247
+ TSTRING text() const {
1248
+ TSTRING out = head_;
1249
+ if (seen_ > head_.length() + tailLength_) out += " ... ";
1250
+ for (size_t i = 0; i < tailLength_; ++i) {
1251
+ out += tail_[(tailStart_ + i) % GATEWAY_RESPONSE_TAIL_BYTES];
1252
+ }
1253
+ return out;
1254
+ }
1255
+
1256
+ private:
1257
+ TSTRING head_;
1258
+ char tail_[GATEWAY_RESPONSE_TAIL_BYTES] = {};
1259
+ size_t tailStart_ = 0;
1260
+ size_t tailLength_ = 0;
1261
+ size_t seen_ = 0;
1262
+ };
1263
+
1264
+ /**
1265
+ * @brief Removes HTTP/1.1 chunked transfer framing from a body read a byte at
1266
+ * a time, and passes the content on to a ResponseExcerpt
1267
+ *
1268
+ * HTTPClient's getString() decodes a chunked body but keeps all of it; the
1269
+ * gateway reads the raw stream to keep only a bounded excerpt, and the raw
1270
+ * stream is the framing: CallMeBot's real reply reached the application as
1271
+ * "a6 Message to: ... Message queued. ... 0" (hardware rig, 2026-09-15) --
1272
+ * the chunk size in front, the terminating zero-length chunk behind. This
1273
+ * reads the framing (hex size, optional ;extensions, CRLF, data, CRLF, ...,
1274
+ * 0, trailers) and forwards only the data. A malformed size line stops
1275
+ * decoding rather than guessing: what was decoded so far is kept.
1276
+ */
1277
+ class ChunkedBodyDecoder {
1278
+ public:
1279
+ explicit ChunkedBodyDecoder(ResponseExcerpt& excerpt) : excerpt_(excerpt) {}
1280
+
1281
+ /** One byte of the raw body; false once the body ended or the excerpt is
1282
+ * full, when the caller can stop reading. */
1283
+ bool add(char c) {
1284
+ switch (state_) {
1285
+ case State::Size:
1286
+ if (c == '\r') {
1287
+ state_ = State::SizeLf;
1288
+ } else if (c == ';') {
1289
+ state_ = State::Extension;
1290
+ } else if (c == ' ' || c == '\t') {
1291
+ // Tolerated around the size, as many servers emit it.
1292
+ } else {
1293
+ const int digit = hexValue(c);
1294
+ if (digit < 0 || sizeDigits_ >= 8) return stop();
1295
+ remaining_ = (remaining_ << 4) | static_cast<uint32_t>(digit);
1296
+ ++sizeDigits_;
1297
+ }
1298
+ return true;
1299
+ case State::Extension:
1300
+ if (c == '\r') state_ = State::SizeLf;
1301
+ return true;
1302
+ case State::SizeLf:
1303
+ if (c != '\n' || sizeDigits_ == 0) return stop();
1304
+ sizeDigits_ = 0;
1305
+ if (remaining_ == 0) {
1306
+ state_ = State::Done;
1307
+ return false;
1308
+ }
1309
+ state_ = State::Data;
1310
+ return true;
1311
+ case State::Data:
1312
+ --remaining_;
1313
+ if (remaining_ == 0) state_ = State::DataCr;
1314
+ if (!excerpt_.add(c)) {
1315
+ state_ = State::Done;
1316
+ return false;
1317
+ }
1318
+ return true;
1319
+ case State::DataCr:
1320
+ if (c != '\r') return stop();
1321
+ state_ = State::DataLf;
1322
+ return true;
1323
+ case State::DataLf:
1324
+ if (c != '\n') return stop();
1325
+ state_ = State::Size;
1326
+ return true;
1327
+ case State::Done:
1328
+ return false;
1329
+ }
1330
+ return false;
1331
+ }
1332
+
1333
+ void add(const TSTRING& raw) {
1334
+ for (size_t i = 0; i < raw.length(); ++i) {
1335
+ if (!add(raw[i])) return;
1336
+ }
1337
+ }
1338
+
1339
+ /** True when the terminating zero-length chunk was read. */
1340
+ bool complete() const { return state_ == State::Done && !malformed_; }
1341
+
1342
+ private:
1343
+ enum class State { Size, Extension, SizeLf, Data, DataCr, DataLf, Done };
1344
+
1345
+ static int hexValue(char c) {
1346
+ if (c >= '0' && c <= '9') return c - '0';
1347
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
1348
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
1349
+ return -1;
1350
+ }
1351
+
1352
+ bool stop() {
1353
+ malformed_ = true;
1354
+ state_ = State::Done;
1355
+ return false;
1356
+ }
1357
+
1358
+ ResponseExcerpt& excerpt_;
1359
+ State state_ = State::Size;
1360
+ uint32_t remaining_ = 0;
1361
+ uint8_t sizeDigits_ = 0;
1362
+ bool malformed_ = false;
1363
+ };
1364
+
1365
+ /** Whether a Transfer-Encoding header value names chunked encoding. */
1366
+ inline bool transferEncodingIsChunked(const TSTRING& value) {
1367
+ TSTRING lower;
1368
+ for (size_t i = 0; i < value.length(); ++i) {
1369
+ const char c = value[i];
1370
+ lower += (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
1371
+ }
1372
+ return responseContains(lower, "chunked");
1373
+ }
1374
+
1375
+ /**
1376
+ * @brief The delay a Retry-After header asks for, in milliseconds
1377
+ *
1378
+ * Only the delay-seconds form is read; an HTTP-date, or anything else, is
1379
+ * 0 -- no instruction -- rather than a guess. Values past
1380
+ * GATEWAY_RETRY_AFTER_MAX_MS are returned as they are, so the caller can see
1381
+ * the server asked for longer than it will wait.
1382
+ */
1383
+ inline uint32_t parseRetryAfterMs(const TSTRING& value) {
1384
+ uint64_t seconds = 0;
1385
+ size_t digits = 0;
1386
+ for (size_t i = 0; i < value.length(); ++i) {
1387
+ const char c = value[i];
1388
+ if (c == ' ' || c == '\t') {
1389
+ if (digits == 0) continue;
1390
+ break;
1391
+ }
1392
+ if (c < '0' || c > '9') return 0;
1393
+ seconds = seconds * 10 + static_cast<uint64_t>(c - '0');
1394
+ if (seconds > 0xFFFFFFFFULL / 1000ULL) return 0xFFFFFFFFUL;
1395
+ ++digits;
1396
+ }
1397
+ return digits == 0 ? 0 : static_cast<uint32_t>(seconds * 1000ULL);
1398
+ }
1399
+
1400
+ /**
1401
+ * @brief The identifier the gateway sends with a request, as both
1402
+ * X-Request-Id and Idempotency-Key
1403
+ *
1404
+ * The same for every attempt at one sendToInternet() call, so a service that
1405
+ * honours Idempotency-Key treats a retry as the request it already has, and
1406
+ * anything recording requests can count the copies one call produced.
1407
+ *
1408
+ * The request's nonce keeps it unique beyond its messageId, whose counter
1409
+ * wraps after 65,535 requests in a boot. A package from a node that predates
1410
+ * the nonce (0) keeps the two-part form it always had.
1411
+ */
1412
+ inline TSTRING requestIdFor(uint32_t originNode, uint32_t messageId,
1413
+ uint32_t requestNonce = 0) {
1414
+ char buffer[40];
1415
+ if (requestNonce == 0) {
1416
+ snprintf(buffer, sizeof(buffer), "pm-%08x-%08x",
1417
+ static_cast<unsigned>(originNode), static_cast<unsigned>(messageId));
1418
+ } else {
1419
+ snprintf(buffer, sizeof(buffer), "pm-%08x-%08x-%08x",
1420
+ static_cast<unsigned>(originNode), static_cast<unsigned>(messageId),
1421
+ static_cast<unsigned>(requestNonce));
1422
+ }
1423
+ return TSTRING(buffer);
1424
+ }
1425
+
1426
+ /** A request nonce: random, and never the 0 that means "none". */
1427
+ inline uint32_t newRequestNonce() {
1428
+ uint32_t nonce = validation::SecureRandom::generate();
1429
+ return nonce != 0 ? nonce : 1;
1430
+ }
1431
+
1432
+ /**
1433
+ * @brief Classify an HTTPClient result for the gateway acknowledgment
1434
+ *
1435
+ * HTTP semantics only. 200, 201, 202 and 204 are a success. Any other 2xx is
1436
+ * an unverified failure the server answered; 1xx, 3xx, 4xx and 5xx are
1437
+ * failures. A retry is allowed only when it cannot produce a second copy of
1438
+ * the request: a transport error that failed before the request was complete,
1439
+ * or 429/503, where the server says it did not take the request. The reason
1440
+ * is a summary of whatever body was read, so the application learns why.
999
1441
  *
1000
1442
  * @param rawCode The int returned by HTTPClient::GET() or ::POST()
1443
+ * @param body The start of the response body, empty if none was read
1001
1444
  * @return Classified outcome, safe to place in a GatewayAckPackage
1002
1445
  */
1003
- inline HttpRequestOutcome classifyHttpResult(int rawCode) {
1446
+ inline HttpRequestOutcome classifyHttpResult(int rawCode,
1447
+ const TSTRING& body = TSTRING()) {
1004
1448
  HttpRequestOutcome outcome;
1005
1449
  if (rawCode <= 0) {
1006
1450
  outcome.transportError = true;
1451
+ outcome.retryable = !transportErrorMayHaveReachedServer(rawCode);
1007
1452
  return outcome;
1008
1453
  }
1009
1454
  outcome.ackStatus =
1010
1455
  static_cast<uint16_t>(rawCode > 0xFFFF ? 0xFFFF : rawCode);
1011
- outcome.success = (rawCode == 200 || rawCode == 201 || rawCode == 202 ||
1012
- rawCode == 204);
1456
+ outcome.success =
1457
+ rawCode == 200 || rawCode == 201 || rawCode == 202 || rawCode == 204;
1458
+ outcome.unverifiedStatus = !outcome.success && rawCode >= 200 && rawCode < 300;
1459
+ outcome.retryable = rawCode == 429 || rawCode == 503;
1460
+ if (!outcome.success) outcome.reason = summarizeResponseBody(body);
1013
1461
  return outcome;
1014
1462
  }
1015
1463
 
1464
+ /**
1465
+ * The phrase a node puts in a failed GATEWAY_ACK when it received a gateway
1466
+ * request but serves none: a bridge that rebooted, crashed or was reflashed
1467
+ * as a regular node announces nothing, and its peers keep routing Internet
1468
+ * requests to it until its last bridge status ages out. The origin node
1469
+ * recognises the phrase, forgets that node as a gateway, and retries through
1470
+ * the next one. Changing it breaks that recognition across a mixed fleet.
1471
+ */
1472
+ static const char GATEWAY_NOT_A_GATEWAY_PHRASE[] = "is not an Internet gateway";
1473
+
1474
+ /**
1475
+ * How long a destination whose name failed to resolve is refused without
1476
+ * another lookup (issue #453). On ESP32 a DNS lookup has no timeout this
1477
+ * library can set, so a dead name stalls the cooperative scheduler for the
1478
+ * resolver's own patience; once a minute is survivable, once per attempt --
1479
+ * with the origin node retrying and the bridge's own sends adding theirs --
1480
+ * was not. On ESP8266 the core bounds HTTPClient's own lookup by the HTTP
1481
+ * timeout, so the gateway performs no separate lookup there and this cache
1482
+ * is never fed.
1483
+ */
1484
+ static const uint32_t GATEWAY_DNS_NEGATIVE_TTL_MS = 60000;
1485
+
1486
+ /**
1487
+ * @brief The host of an http(s) URL, without scheme, port, path or query
1488
+ *
1489
+ * "https://api.example.com:8443/x?y" -> "api.example.com". Empty when the
1490
+ * URL has no host.
1491
+ */
1492
+ inline TSTRING hostFromUrl(const TSTRING& url) {
1493
+ const char* s = url.c_str();
1494
+ const size_t n = url.length();
1495
+ size_t i = 0;
1496
+ for (size_t k = 0; k + 2 < n; ++k) {
1497
+ if (s[k] == ':' && s[k + 1] == '/' && s[k + 2] == '/') {
1498
+ i = k + 3;
1499
+ break;
1500
+ }
1501
+ }
1502
+ size_t j = i;
1503
+ while (j < n && s[j] != '/' && s[j] != '?' && s[j] != '#' && s[j] != ':') ++j;
1504
+ TSTRING host;
1505
+ for (size_t k = i; k < j; ++k) host += s[k];
1506
+ return host;
1507
+ }
1508
+
1509
+ /** True for a dotted-decimal IPv4 literal, which needs no DNS. */
1510
+ inline bool looksLikeIpLiteral(const TSTRING& host) {
1511
+ if (host.length() == 0) return false;
1512
+ for (size_t i = 0; i < host.length(); ++i) {
1513
+ const char c = host[i];
1514
+ if (!(c >= '0' && c <= '9') && c != '.') return false;
1515
+ }
1516
+ return true;
1517
+ }
1518
+
1519
+ /**
1520
+ * @brief A few hosts that recently failed to resolve, and when
1521
+ *
1522
+ * Small and fixed: a gateway talks to a handful of destinations. A host is
1523
+ * remembered with a TTL; while it is within it, isFailing() says so and the
1524
+ * caller answers the request without a lookup.
1525
+ */
1526
+ class NegativeDnsCache {
1527
+ public:
1528
+ // An enum, not a static const member: the test suite passes it to Catch2 by
1529
+ // reference, which needs a definition C++14 cannot give an in-class
1530
+ // constant without an out-of-line one.
1531
+ enum : size_t { SLOTS = 4 };
1532
+
1533
+ void remember(const TSTRING& host, uint32_t nowMs,
1534
+ uint32_t ttlMs = GATEWAY_DNS_NEGATIVE_TTL_MS) {
1535
+ Entry* slot = find(host);
1536
+ if (slot == nullptr) {
1537
+ slot = &entries_[0];
1538
+ for (size_t i = 0; i < SLOTS; ++i) {
1539
+ if (!entries_[i].used) {
1540
+ slot = &entries_[i];
1541
+ break;
1542
+ }
1543
+ if (static_cast<int32_t>(entries_[i].at - slot->at) < 0) slot = &entries_[i];
1544
+ }
1545
+ }
1546
+ slot->used = true;
1547
+ slot->host = host;
1548
+ slot->at = nowMs;
1549
+ slot->ttl = ttlMs;
1550
+ }
1551
+
1552
+ /** Is `host` inside its negative TTL? `ageMs` receives how long ago it failed. */
1553
+ bool isFailing(const TSTRING& host, uint32_t nowMs, uint32_t* ageMs = nullptr) {
1554
+ Entry* slot = find(host);
1555
+ if (slot == nullptr) return false;
1556
+ const uint32_t age = nowMs - slot->at;
1557
+ if (age >= slot->ttl) {
1558
+ slot->used = false;
1559
+ return false;
1560
+ }
1561
+ if (ageMs != nullptr) *ageMs = age;
1562
+ return true;
1563
+ }
1564
+
1565
+ void forget(const TSTRING& host) {
1566
+ Entry* slot = find(host);
1567
+ if (slot != nullptr) slot->used = false;
1568
+ }
1569
+
1570
+ size_t size() const {
1571
+ size_t n = 0;
1572
+ for (size_t i = 0; i < SLOTS; ++i) n += entries_[i].used ? 1 : 0;
1573
+ return n;
1574
+ }
1575
+
1576
+ private:
1577
+ struct Entry {
1578
+ TSTRING host;
1579
+ uint32_t at = 0;
1580
+ uint32_t ttl = 0;
1581
+ bool used = false;
1582
+ };
1583
+
1584
+ Entry* find(const TSTRING& host) {
1585
+ for (size_t i = 0; i < SLOTS; ++i) {
1586
+ if (entries_[i].used && entries_[i].host == host) return &entries_[i];
1587
+ }
1588
+ return nullptr;
1589
+ }
1590
+
1591
+ Entry entries_[SLOTS];
1592
+ };
1593
+
1016
1594
  /**
1017
1595
  * @brief Gateway Acknowledgment Package for delivery confirmations
1018
1596
  *
@@ -1116,13 +1694,40 @@ class GatewayAckPackage : public plugin::SinglePackage {
1116
1694
  */
1117
1695
  uint32_t timestamp = 0;
1118
1696
 
1697
+ /**
1698
+ * @brief The start of the response body, summarized to one line
1699
+ *
1700
+ * Carried on success as well as failure, because whether a reply means what
1701
+ * the application wanted is the application's decision, not the library's.
1702
+ * Empty when no body was read. JSON key "resp", omitted when empty.
1703
+ */
1704
+ TSTRING response = "";
1705
+
1706
+ /**
1707
+ * @brief Whether the origin node may resend the identical request
1708
+ *
1709
+ * 1 when the request cannot have reached the server or the server asked to
1710
+ * be retried, 0 when a resend could deliver it twice. -1 when the gateway
1711
+ * did not say -- one that predates the field -- and the origin node falls
1712
+ * back to its own reading of the status. JSON key "retry", omitted at -1.
1713
+ */
1714
+ int8_t retryable = -1;
1715
+
1716
+ /**
1717
+ * @brief How long the server asked to wait before a retry, in milliseconds
1718
+ *
1719
+ * From a Retry-After header on a 429 or 503. JSON key "retryAfter",
1720
+ * omitted when 0.
1721
+ */
1722
+ uint32_t retryAfterMs = 0;
1723
+
1119
1724
  /**
1120
1725
  * @brief Number of additional JSON fields in this package
1121
1726
  *
1122
1727
  * Used for jsonObjectSize() calculation in ArduinoJson v6.
1123
- * Count: msgId, origin, success, http, err, ts = 6 fields
1728
+ * Count: msgId, origin, success, http, err, ts, resp, retry, retryAfter = 9
1124
1729
  */
1125
- static constexpr int numPackageFields = 6;
1730
+ static constexpr int numPackageFields = 9;
1126
1731
 
1127
1732
  /**
1128
1733
  * @brief Default constructor
@@ -1149,10 +1754,19 @@ class GatewayAckPackage : public plugin::SinglePackage {
1149
1754
  #if ARDUINOJSON_VERSION_MAJOR < 7
1150
1755
  if (jsonObj.containsKey("err"))
1151
1756
  error = jsonObj["err"].as<TSTRING>();
1757
+ if (jsonObj.containsKey("resp"))
1758
+ response = jsonObj["resp"].as<TSTRING>();
1759
+ if (jsonObj.containsKey("retry"))
1760
+ retryable = jsonObj["retry"].as<bool>() ? 1 : 0;
1152
1761
  #else
1153
1762
  if (jsonObj["err"].is<TSTRING>())
1154
1763
  error = jsonObj["err"].as<TSTRING>();
1764
+ if (jsonObj["resp"].is<TSTRING>())
1765
+ response = jsonObj["resp"].as<TSTRING>();
1766
+ if (jsonObj["retry"].is<bool>())
1767
+ retryable = jsonObj["retry"].as<bool>() ? 1 : 0;
1155
1768
  #endif
1769
+ retryAfterMs = jsonObj["retryAfter"] | 0UL;
1156
1770
  }
1157
1771
 
1158
1772
  /**
@@ -1171,6 +1785,11 @@ class GatewayAckPackage : public plugin::SinglePackage {
1171
1785
  jsonObj["http"] = httpStatus;
1172
1786
  jsonObj["err"] = error;
1173
1787
  jsonObj["ts"] = timestamp;
1788
+ // Added in 2.1.0 and omitted when they say nothing, so an ack to a node
1789
+ // that predates them is the ack it always was.
1790
+ if (response.length() > 0) jsonObj["resp"] = response;
1791
+ if (retryable >= 0) jsonObj["retry"] = retryable == 1;
1792
+ if (retryAfterMs > 0) jsonObj["retryAfter"] = retryAfterMs;
1174
1793
  return jsonObj;
1175
1794
  }
1176
1795
 
@@ -1184,7 +1803,8 @@ class GatewayAckPackage : public plugin::SinglePackage {
1184
1803
  */
1185
1804
  size_t jsonObjectSize() const {
1186
1805
  // noJsonFields (from base class) + numPackageFields (our fields)
1187
- return JSON_OBJECT_SIZE(noJsonFields + numPackageFields) + error.length();
1806
+ return JSON_OBJECT_SIZE(noJsonFields + numPackageFields) + error.length() +
1807
+ response.length();
1188
1808
  }
1189
1809
  #endif
1190
1810
 
@@ -1262,13 +1882,15 @@ class GatewayAckPackage : public plugin::SinglePackage {
1262
1882
  * GATEWAY_DNS_TIMEOUT_MS on ESP8266, skipped on ESP32) runs before them
1263
1883
  * whenever the GATEWAY_CONNECTIVITY_CACHE_MS window has expired.
1264
1884
  *
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".
1885
+ * @warning One residual is not in this budget: hostname resolution on ESP32
1886
+ * is not separately boundable in the cores this library targets.
1887
+ * Since issue #453 the handler performs that lookup itself on
1888
+ * ESP32 and remembers a failure for GATEWAY_DNS_NEGATIVE_TTL_MS, so
1889
+ * on a network with blackholed DNS the request path can still
1890
+ * exceed this ceiling on ESP32, but at most once per TTL per
1891
+ * destination host rather than once per attempt. On ESP8266 the
1892
+ * core bounds HTTPClient's own lookup by the HTTP timeout. See
1893
+ * SECURITY.md "Gateway blocking: the mesh partition risk".
1272
1894
  */
1273
1895
  constexpr unsigned long gatewayBlockingBudgetMs() {
1274
1896
  return 2UL * static_cast<unsigned long>(GATEWAY_HTTP_TIMEOUT_MS) +