@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.
@@ -2721,54 +2721,56 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2721
2721
  /**
2722
2722
  * Helper method to send gateway acknowledgment
2723
2723
  */
2724
- void sendGatewayAck(
2725
- const gateway::GatewayDataPackage& request, bool success,
2726
- uint16_t httpStatus, const TSTRING& error,
2727
- std::shared_ptr<Connection> ingressConnection = nullptr) {
2728
- using namespace logger;
2729
-
2730
- gateway::GatewayAckPackage ack;
2731
- ack.from = this->nodeId;
2732
- ack.dest = request.originNode;
2733
- ack.messageId = request.messageId;
2734
- ack.originNode = request.originNode;
2735
- ack.success = success;
2736
- ack.httpStatus = httpStatus;
2737
- ack.error = error;
2738
- ack.timestamp = this->getNodeTime();
2739
-
2740
- if (request.originNode == this->nodeId) {
2741
- protocol::Variant variant(&ack);
2742
- this->callbackList.execute(protocol::GATEWAY_ACK, variant, nullptr, 0);
2743
- Log(COMMUNICATION,
2744
- "Completed local GATEWAY_ACK (success=%d, http=%d)\n", success,
2745
- httpStatus);
2746
- return;
2747
- }
2724
+ /** Hosts that recently failed to resolve; fed on ESP32 only, see the
2725
+ * GATEWAY_DATA handler. */
2726
+ gateway::NegativeDnsCache negativeDns;
2748
2727
 
2749
- auto conn = router::findRoute<Connection>((*this), request.originNode);
2750
- if (!conn && ingressConnection) {
2751
- // A newly promoted gateway can receive data before its NodeTree has
2752
- // converged enough for findRoute() to resolve the request origin. The
2753
- // ingress connection is nevertheless a valid reverse path: the request
2754
- // just arrived through it and every intermediate node can continue
2755
- // routing the addressed acknowledgment toward originNode.
2756
- conn = ingressConnection;
2757
- Log(COMMUNICATION,
2758
- "Routing GATEWAY_ACK to node %u through request ingress while "
2759
- "topology converges\n",
2760
- request.originNode);
2761
- }
2762
- if (conn) {
2763
- protocol::Variant variant(&ack);
2764
- router::send(std::move(variant), conn);
2765
- Log(COMMUNICATION, "Sent GATEWAY_ACK to node %u (success=%d, http=%d)\n",
2766
- request.originNode, success, httpStatus);
2767
- } else {
2768
- Log(ERROR, "Failed to send GATEWAY_ACK: no route to node %u\n",
2769
- request.originNode);
2728
+ #if defined(ESP32) || defined(ESP8266)
2729
+ /**
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.
2736
+ */
2737
+ static TSTRING readResponseExcerpt(HTTPClient& http) {
2738
+ const int size = http.getSize();
2739
+ if (size >= 0 &&
2740
+ static_cast<size_t>(size) <=
2741
+ gateway::GATEWAY_RESPONSE_HEAD_BYTES + gateway::GATEWAY_RESPONSE_TAIL_BYTES) {
2742
+ return http.getString();
2743
+ }
2744
+ gateway::ResponseExcerpt excerpt;
2745
+ WiFiClient* stream = http.getStreamPtr();
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);
2750
+ const uint32_t deadline =
2751
+ millis() + gateway::GATEWAY_RESPONSE_HEAD_TIMEOUT_MS;
2752
+ bool more = true;
2753
+ while (more && static_cast<int32_t>(deadline - millis()) > 0) {
2754
+ int available = stream->available();
2755
+ if (available <= 0) {
2756
+ if (!stream->connected()) break;
2757
+ delay(1);
2758
+ continue;
2759
+ }
2760
+ while (more && available-- > 0) {
2761
+ const int c = stream->read();
2762
+ if (c < 0) break;
2763
+ more = chunked ? decoder.add(static_cast<char>(c))
2764
+ : excerpt.add(static_cast<char>(c));
2765
+ }
2770
2766
  }
2767
+ return excerpt.text();
2771
2768
  }
2769
+ #endif
2770
+
2771
+ // sendGatewayAck() is portable and lives in painlessmesh::Mesh
2772
+ // (mesh.hpp): every node needs it, not only a gateway, to answer a
2773
+ // request it cannot serve.
2772
2774
 
2773
2775
  /**
2774
2776
  * Initialize gateway Internet handler
@@ -2837,8 +2839,13 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2837
2839
  // that genuinely stopped answering NODE_SYNC still gets reaped even
2838
2840
  // under continuous gateway traffic from other peers.
2839
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.
2840
2845
  auto finish = [this, &pkg, ingress, blockingStartedMs](
2841
- 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) {
2842
2849
  const auto stalledMs = millis() - blockingStartedMs;
2843
2850
  auto refreshed = gateway::refreshPeerWatchdogs(*this, stalledMs);
2844
2851
  if (refreshed > 0) {
@@ -2848,31 +2855,75 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2848
2855
  static_cast<unsigned>(refreshed),
2849
2856
  static_cast<unsigned long>(stalledMs));
2850
2857
  }
2851
- this->sendGatewayAck(pkg, ok, code, err, ingress);
2858
+ this->sendGatewayAck(pkg, ok, code, err, ingress, response, retryable,
2859
+ retryAfterMs);
2852
2860
  };
2853
2861
 
2854
2862
  // Check Internet connectivity
2855
2863
  // First check WiFi status for quick fail
2856
2864
  if (WiFi.status() != WL_CONNECTED) {
2857
- finish(false, 0, "Gateway WiFi not connected");
2865
+ finish(false, 0, "Gateway WiFi not connected", 0);
2858
2866
  return true; // Consume package - we handled it (with error)
2859
2867
  }
2860
2868
 
2861
2869
  // Then check actual internet access (DNS resolution)
2862
2870
  // This detects when WiFi is connected but router has no internet
2863
2871
  if (!hasActualInternetAccess()) {
2864
- finish(false, 0, "Router has no internet access - check WAN connection");
2872
+ finish(false, 0, "Router has no internet access - check WAN connection", 0);
2865
2873
  return true; // Consume package - we handled it (with error)
2866
2874
  }
2867
2875
 
2868
2876
  // Finally, check for captive portal interference
2869
2877
  // This detects when DNS works but HTTP requests are intercepted
2870
2878
  if (!detectCaptivePortal()) {
2871
- 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);
2872
2880
  return true; // Consume package - we handled it (with error)
2873
2881
  }
2874
2882
 
2875
2883
  #if defined(ESP32) || defined(ESP8266)
2884
+ #ifdef ESP32
2885
+ // A destination whose name failed to resolve is refused for
2886
+ // GATEWAY_DNS_NEGATIVE_TTL_MS without another lookup (issue #453).
2887
+ // HTTPClient reports a failed lookup as "connection refused" after
2888
+ // the resolver's own patience -- on ESP32 not boundable by this
2889
+ // library -- and it did so on every attempt: the origin node's
2890
+ // retries and, since #451, the bridge's own sends each paid it, and
2891
+ // the scheduler stalled long enough for relayed requests to time
2892
+ // out on their origin nodes. Resolving here, once, names the real
2893
+ // failure and caps the stall to one lookup per TTL per host.
2894
+ // ESP32 only: the ESP8266 core bounds HTTPClient's own lookup by the
2895
+ // HTTP timeout, and a second bounded wait would not fit the
2896
+ // blocking budget.
2897
+ const TSTRING host = gateway::hostFromUrl(pkg.destination);
2898
+ if (host.length() > 0 && !gateway::looksLikeIpLiteral(host)) {
2899
+ uint32_t failedAgoMs = 0;
2900
+ if (negativeDns.isFailing(host, millis(), &failedAgoMs)) {
2901
+ char dnsBuf[160];
2902
+ snprintf(dnsBuf, sizeof(dnsBuf),
2903
+ "DNS lookup for %s failed %lus ago; not retried for "
2904
+ "another %lus",
2905
+ host.c_str(),
2906
+ static_cast<unsigned long>(failedAgoMs / 1000),
2907
+ static_cast<unsigned long>(
2908
+ (gateway::GATEWAY_DNS_NEGATIVE_TTL_MS - failedAgoMs) /
2909
+ 1000));
2910
+ Log(ERROR, "%s\n", dnsBuf);
2911
+ finish(false, 0, TSTRING(dnsBuf), 0);
2912
+ return true;
2913
+ }
2914
+ IPAddress resolved;
2915
+ if (WiFi.hostByName(host.c_str(), resolved) != 1) {
2916
+ negativeDns.remember(host, millis());
2917
+ char dnsBuf[160];
2918
+ snprintf(dnsBuf, sizeof(dnsBuf), "DNS lookup failed for %s",
2919
+ host.c_str());
2920
+ Log(ERROR, "%s\n", dnsBuf);
2921
+ finish(false, 0, TSTRING(dnsBuf), 0);
2922
+ return true;
2923
+ }
2924
+ }
2925
+ #endif // ESP32
2926
+
2876
2927
  // Make HTTP/HTTPS request
2877
2928
  HTTPClient http;
2878
2929
  http.setTimeout(GATEWAY_HTTP_TIMEOUT_MS);
@@ -2917,6 +2968,17 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2917
2968
  #endif
2918
2969
  }
2919
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
+
2920
2982
  // Make request (GET if no payload, POST if payload)
2921
2983
  if (pkg.payload.length() > 0) {
2922
2984
  http.addHeader("Content-Type", pkg.contentType.c_str());
@@ -2925,67 +2987,82 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2925
2987
  httpCode = http.GET();
2926
2988
  }
2927
2989
 
2928
- // Only specific 2xx status codes indicate genuine success:
2929
- // 200 OK, 201 Created, 202 Accepted, 204 No Content.
2930
- //
2931
- // Other 2xx codes like 203 (Non-Authoritative Information) often
2932
- // indicate cached/proxied responses that may not represent actual
2933
- // delivery to the destination service (e.g., WhatsApp API).
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.
2934
2995
  //
2935
2996
  // 3xx redirects are not automatically followed.
2936
2997
  //
2937
2998
  // The classification lives in painlessmesh::gateway so the desktop
2938
2999
  // test suite can exercise it; this file is ESP-only and never
2939
3000
  // compiled by the Catch2 build.
2940
- const auto outcome = gateway::classifyHttpResult(httpCode);
3001
+ TSTRING responseHead;
3002
+ if (httpCode > 0) {
3003
+ responseHead =
3004
+ readResponseExcerpt(http);
3005
+ }
3006
+ const auto outcome =
3007
+ gateway::classifyHttpResult(httpCode, responseHead);
2941
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
+ }
2942
3015
 
2943
3016
  if (!outcome.transportError) {
3017
+ char errorBuf[320];
3018
+ const char* reason = outcome.reason.c_str();
3019
+ const char* sep = outcome.reason.length() > 0 ? ": " : "";
2944
3020
  if (success) {
2945
3021
  Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
2946
- } else if (httpCode >= 200 && httpCode < 300) {
2947
- // Other 2xx codes - ambiguous success
2948
- // HTTP 203 is retryable, so log at COMMUNICATION level to reduce noise
2949
- char errorBuf[128];
2950
- snprintf(errorBuf, sizeof(errorBuf),
2951
- "Ambiguous response - HTTP %d may indicate cached/proxied response, not actual delivery",
2952
- httpCode);
2953
- error = TSTRING(errorBuf);
2954
- Log(COMMUNICATION, "HTTP request ambiguous: code=%d (treated as failure, will retry)\n", httpCode);
2955
- } else if (httpCode >= 500 && httpCode < 600) {
2956
- // 5xx server errors are retryable, log at COMMUNICATION level
2957
- char errorBuf[32];
2958
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3022
+ } else if (outcome.unverifiedStatus) {
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.
3027
+ snprintf(errorBuf, sizeof(errorBuf),
3028
+ "HTTP %d: not a delivery the gateway can confirm%s%s",
3029
+ httpCode, sep, reason);
2959
3030
  error = TSTRING(errorBuf);
2960
- Log(COMMUNICATION, "HTTP server error: code=%d (will retry)\n", httpCode);
2961
- } else if (httpCode == 429) {
2962
- // HTTP 429 rate limit is retryable, log at COMMUNICATION level
2963
- char errorBuf[32];
2964
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3031
+ Log(ERROR, "HTTP response unverified: %s\n", errorBuf);
3032
+ } else if (outcome.retryable) {
3033
+ // 429 or 503: the server says it did not take the request.
3034
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
3035
+ reason);
2965
3036
  error = TSTRING(errorBuf);
2966
- 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));
2967
3039
  } else {
2968
- // 1xx, 3xx, 4xx (except 429) - non-retryable, log at ERROR level
2969
- char errorBuf[32];
2970
- snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
3040
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d%s%s", httpCode, sep,
3041
+ reason);
2971
3042
  error = TSTRING(errorBuf);
2972
- Log(ERROR, "HTTP request failed: code=%d\n", httpCode);
3043
+ Log(ERROR, "HTTP request failed: %s\n", errorBuf);
2973
3044
  }
2974
3045
  } else {
2975
- // Network errors (httpCode <= 0) are retryable but indicate serious issues
2976
- // Keep at ERROR level as they may indicate gateway connectivity problems
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.
2977
3050
  error = http.errorToString(httpCode);
3051
+ if (!outcome.retryable) {
3052
+ error += " (the request may have reached the server; not retried)";
3053
+ }
2978
3054
  Log(ERROR, "HTTP request failed: %s\n", error.c_str());
2979
3055
  }
2980
3056
 
2981
3057
  http.end();
2982
3058
 
2983
- // Send acknowledgment back. Transport errors carry status 0, the
2984
- // value handleGatewayAck() treats as a retryable network error.
2985
- 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);
2986
3063
  #else
2987
3064
  // Non-ESP platform - send error
2988
- finish(false, 0, "HTTP client not available on this platform");
3065
+ finish(false, 0, "HTTP client not available on this platform", 0);
2989
3066
  #endif
2990
3067
 
2991
3068
  return true; // Consume package - we have processed it and sent
@@ -2993,6 +3070,20 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2993
3070
  });
2994
3071
  }
2995
3072
 
3073
+ /**
3074
+ * The station just got an IP. On a node that runs the Internet health
3075
+ * check -- a bridge or a shared gateway, whose station is the manual router
3076
+ * link -- the last probe most likely ran during association and failed,
3077
+ * and the next is a full interval away (issue #450). Probe again now, from
3078
+ * the scheduler rather than the Wi-Fi event task, so hasLocalInternet()
3079
+ * and the bridge's own sends stop waiting out the interval. A regular
3080
+ * node's mesh station never enables the check and is left alone.
3081
+ */
3082
+ void probeUplinkAfterAssociation() {
3083
+ if (!this->isInternetHealthCheckEnabled() || !stationScan.manual) return;
3084
+ this->addTask([this]() { this->checkInternetNow(); });
3085
+ }
3086
+
2996
3087
  void eventHandleInit() {
2997
3088
  using namespace logger;
2998
3089
  // Where the station scan learns the channel the mesh is rooted on.
@@ -3101,6 +3192,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
3101
3192
  this->stationScan.stationAttemptOver();
3102
3193
  this->stationScan.stationUp();
3103
3194
  this->tcpConnect(); // Connect to TCP port
3195
+ this->probeUplinkAfterAssociation();
3104
3196
  this->semaphoreGive();
3105
3197
  }
3106
3198
  },
@@ -3137,6 +3229,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
3137
3229
  this->stationScan.stationAttemptOver();
3138
3230
  this->stationScan.stationUp();
3139
3231
  this->tcpConnect(); // Connect to TCP port
3232
+ this->probeUplinkAfterAssociation();
3140
3233
  });
3141
3234
  #endif // ESP32
3142
3235
  return;
@@ -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
- // else scan fast (SCAN_INTERVAL)
736
- Log(CONNECTION,
737
- "connectToAP(): No unknown nodes found scan rate set to "
738
- "fast\n");
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 {
@@ -62,7 +62,14 @@ class StationScan {
62
62
  // The station got an address: its link is up. Cleared once the drop
63
63
  // callbacks have judged a disconnect, so they can tell a link that was
64
64
  // up and went away from an attempt that never got that far.
65
- void stationUp() { stationLinkUp = true; }
65
+ void stationUp() {
66
+ stationLinkUp = true;
67
+ // A link again, so the immediate-rescan allowance is restored now
68
+ // rather than at the next scan. Without this a node that dropped
69
+ // twice inside one scan interval paid the full fifteen seconds for
70
+ // the second drop (#459).
71
+ rejoinScanned = false;
72
+ }
66
73
  void stationDown() { stationLinkUp = false; }
67
74
  bool stationLinkUp = false;
68
75
  // The station link was closed by this node's own channel move a moment
@@ -147,6 +154,11 @@ class StationScan {
147
154
  // channel re-detection runs, and consuming those results here found them
148
155
  // already deleted, reported "wifi scan failed", and rescanned at once.
149
156
  bool scanRequested = false;
157
+ // Whether this node has already scanned at once on losing its last
158
+ // connection. Cleared the moment it has one again, so the immediate
159
+ // rescan is one per outage and not a scan loop: a node that is out of
160
+ // the mesh and finds nothing still falls back to the interval below.
161
+ bool rejoinScanned = false;
150
162
  // Set when the next station scan must cover every channel: the empty
151
163
  // scans have piled up and the node is looking for the channel the mesh
152
164
  // moved to. The re-detection is this task's ordinary asynchronous scan
@@ -206,12 +206,17 @@ class SentBuffer {
206
206
  size_t requestLength(size_t buffer_length) {
207
207
  // Use highest priority message available
208
208
  auto* msg = getNextMessage();
209
- if (!msg)
210
- return 0;
211
- else
212
- // String.toCharArray automatically turns the last character into
213
- // a \0, we need the extra space to deal with that annoyance
214
- return (std::min)(buffer_length - 1, msg->length() + 1);
209
+ if (!msg) return 0;
210
+ // A caller with no room is owed nothing. The subtraction below leaves
211
+ // space for the terminator toCharArray() always writes; on an unsigned
212
+ // zero it wraps to SIZE_MAX instead, min() then picks the message, and
213
+ // the answer comes back *larger* than was asked for -- which the
214
+ // documented contract above forbids and read() then acts on, writing
215
+ // length + 1 bytes into a buffer the caller said had none.
216
+ if (buffer_length == 0) return 0;
217
+ // String.toCharArray automatically turns the last character into
218
+ // a \0, we need the extra space to deal with that annoyance
219
+ return (std::min)(buffer_length - 1, msg->length() + 1);
215
220
  }
216
221
 
217
222
  /**
@@ -76,6 +76,13 @@ class PackageCallbackList {
76
76
  return size;
77
77
  }
78
78
 
79
+ /** How many callbacks are registered for one package id. */
80
+ size_t count(int id) {
81
+ auto generation = clearPending ? pendingCallbackMap : callbackMap;
82
+ auto it = generation->find(id);
83
+ return it == generation->end() ? 0 : it->second.size();
84
+ }
85
+
79
86
  void clear() {
80
87
  if (dispatchDepth > 0) {
81
88
  clearPending = true;