@alteriom/painlessmesh 2.0.3 → 2.1.1

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 CHANGED
@@ -7,6 +7,196 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.1] - 2026-09-21
11
+
12
+ Two crashes the rig and a user found in 2.1.0, and the rig itself wired
13
+ in. A node could crash at start-up on an uninitialised listener pointer
14
+ (#466, reported by a user before the rig saw it), and an ESP8266 serving as
15
+ a shared gateway could run out of memory answering its own
16
+ `sendToInternet()` (#469, found by the rig the day the fix for #466 landed).
17
+ Both are fixed, CI now refuses the class of defect behind #466, and every
18
+ merge to `main` is flashed onto the rig from here on. **Upgrade if you run
19
+ 2.1.0**: the #466 crash is at boot, on every node.
20
+
21
+ ### Added
22
+
23
+ - **Every merge to `main` runs on the hardware rig.**
24
+ `.github/workflows/farm-hil.yml` dispatches the Alteriom farm's HIL suite
25
+ once the CI/CD Pipeline has passed on `main`, and for a pull request a
26
+ maintainer labels `run-hil` -- a promise CONTRIBUTING.md had made that
27
+ nothing kept. The rig had been a manual gate, so #466 reached a user
28
+ before it reached a board. Needs the `FARM_DISPATCH_TOKEN` secret; without
29
+ it the job says so and passes. Release builds stay a person's decision on
30
+ the farm side.
31
+
32
+ - **`mesh.tcpListening()`** -- whether this node's TCP listener exists and
33
+ is in LISTEN, the state peers depend on and nothing else reported. A node
34
+ whose listener was never created (#466) or was re-created not listening
35
+ (#435) is reachable only through connections it made outbound, and nothing
36
+ can join through it; a health report can now say so on the node itself.
37
+ `PAINLESSMESH_HAS_TCP_LISTENING` is defined alongside it for code that
38
+ builds against older releases too.
39
+
40
+ ### Fixed
41
+
42
+ - **An ESP8266 shared gateway could run out of memory answering its own
43
+ `sendToInternet()`** (#469, found by the rig: both ESP8266 boards died of
44
+ `Unhandled C++ exception: OOM` in the `gateway.shared.internet` row, on a
45
+ 76-byte allocation). For a request that originated on the gateway itself,
46
+ the reply travelled to the application as five copies of the ack --
47
+ copied into the deferred task's closure, copied again inside it,
48
+ serialised to JSON by the `Variant` and parsed back into a third package
49
+ by the handler -- then the pending request and the result were copied
50
+ once more on delivery, all on a heap the HTTP client had already spent.
51
+ The ack is now moved into one heap block and handed straight to the
52
+ handler, and the request and result are moved on delivery. What the
53
+ application receives is unchanged.
54
+ - **A node could crash before serving its first connection: `_tcpListener`
55
+ was never initialised** (#466). 2.1.0 taught `tcpServerInit()` to keep a
56
+ listener that already exists instead of re-binding the port, so it now
57
+ reads the pointer before anything has assigned it. On a stack that held
58
+ garbage there the null check passed and the `delete` that followed
59
+ crashed the node at start-up. The pointer defaults to `nullptr`.
60
+ - **The same gap closed everywhere it existed.** Twenty more pointer and
61
+ arithmetic members had no default and no constructor setting them: the
62
+ AP settings a node reports before `init()`; `from`/`dest` on a package
63
+ built by its type constructor and sent before the application filled
64
+ them; the fields of `SendStats`, `ConnectionInfo`, `BridgeCandidate`,
65
+ `MeshChannelCandidate` and `BridgeCoordinationState`; and the
66
+ book-keeping members of `RTCManager`, `MessageQueue`, `LogClass`,
67
+ `metrics::Timer` and the desktop `AsyncServer`. Each now has a default
68
+ member initializer, which holds under every constructor the class has or
69
+ gains later.
70
+ - **CI refuses the next one.** `test/ci/check_member_init.py` walks every
71
+ header under `src/` and fails on any pointer or arithmetic data member
72
+ declared without a default member initializer, whatever the class's
73
+ constructors do; `test/ci/test_check_member_init.sh` plants the #466
74
+ case and proves the checker catches it and passes clean code. Both run
75
+ in the Code Quality job.
76
+
77
+ ## [2.1.0] - 2026-09-15
78
+
79
+ `sendToInternet()` you can build a notifier on (#463). One user's WhatsApp
80
+ alerts through CallMeBot kept failing, and the hardware rig showed why in the
81
+ library: a request whose reply was slow was sent again -- one call reached the
82
+ server four times --, a long reply was cut before the service said what it did,
83
+ and a chunked reply reached the application with its transfer framing. 2.1.0
84
+ issues each request once, retries only what cannot arrive twice, honours
85
+ Retry-After, gives every attempt one request id, and hands the application the
86
+ whole result: status, the service's own words, whether a resend is safe, and
87
+ how many attempts it took. **Upgrade if a node of yours sends to the
88
+ Internet.**
89
+
90
+ **One behaviour change to read before upgrading:** the library no longer
91
+ decides from a reply's words whether a service did what you asked. A CallMeBot
92
+ "Too many requests" page under HTTP 201 is `success == true`, as HTTP says;
93
+ read the reply in your sketch with the new result callback, as
94
+ `examples/sendToInternet/callmebot.h` does. Wire-compatible with 2.0 nodes: the
95
+ new ack and request fields are optional and ignored by older nodes. Validated
96
+ on the six-family hardware rig, including a real WhatsApp delivered through
97
+ the mesh and a real CallMeBot refusal reaching the application intact.
98
+
99
+ ### Changed
100
+
101
+ - **`sendToInternet()` no longer retries a request the server may already
102
+ have.** A read timeout or a connection lost after the request was sent used
103
+ to count as a "network error" and was retried up to three times; on the
104
+ hardware rig one timed-out send reached the server four times, which for a
105
+ message service is four messages, or one message and three refusals. The
106
+ gateway now tells the origin node whether the identical request may be sent
107
+ again (`GatewayAckPackage::retryable`, JSON `"retry"`), and says yes only
108
+ when it cannot have arrived -- connection refused, a failure before the
109
+ request went out -- or when the server said it did not take it: HTTP 429 and
110
+ 503. A 500, 502 or 504 is no longer retried. The error of a transport
111
+ failure that was not retried ends "(the request may have reached the
112
+ server; not retried)", and resending is the application's decision.
113
+ Acks from gateways that predate the field keep their old reading, except
114
+ that a 2xx is never retried.
115
+ - **The library no longer judges a response body.** 2.0.3 matched
116
+ CallMeBot's "Too many requests" page inside the gateway and reported that
117
+ HTTP 201 as a failure. Whether a reply means what an application wanted is
118
+ that service's language, not HTTP's, so the gateway now applies HTTP's
119
+ meaning of the status (200, 201, 202 and 204 succeed; 203, 205-299 are
120
+ unverified failures) and carries the body to the application instead. **A
121
+ CallMeBot rate-limit refusal under HTTP 201 is therefore `success == true`
122
+ again at the library level.** The sendToInternet example reads CallMeBot's
123
+ reply itself (`examples/sendToInternet/callmebot.h`); a sketch that relied
124
+ on 2.0.3's check should do the same with the new result callback.
125
+
126
+ ### Added
127
+
128
+ - **`sendToInternet()` with an `InternetResult` callback.** Besides
129
+ `success`, `httpStatus` and `error`, the result carries `response` -- the
130
+ start of the response body as one line, on success as well as failure --
131
+ `retryable`, `attempts` and `messageId`. The three-argument callback is
132
+ unchanged. The response travels in the ack as `"resp"` and the error
133
+ excerpt keeps the end of a long body as well as its start, so a verdict
134
+ after an echo of the request survives (#463): the gateway keeps the first
135
+ 512 and the last 256 bytes of a body, not only its start, and removes
136
+ chunked transfer framing -- CallMeBot answers chunked, and its reply reached
137
+ the application as "a6 ... Message queued ... 0". `PAINLESSMESH_HAS_INTERNET_RESULT`
138
+ is defined, so code that also builds against older releases can `#ifdef` it.
139
+ - **Retry-After.** A 429 or 503 retry waits at least as long as the server's
140
+ `Retry-After` (delay-seconds); a server that asks for more than 60 s gets no
141
+ automatic retry, and the error says when it wants the request back. The
142
+ request's timeout moves with the wait, so the invited retry is not timed
143
+ out first.
144
+ - **Request ids.** Every attempt at one call carries the same `X-Request-Id`
145
+ and `Idempotency-Key` header (`pm-<origin>-<messageId>-<nonce>`), so a
146
+ service that honours idempotency keys drops a copy and a log can count
147
+ retries. The nonce is drawn per call and travels in the request (`"nonce"`),
148
+ so ids stay unique after the 16-bit message-id counter wraps; a request from
149
+ an older node keeps `pm-<origin>-<messageId>`.
150
+ Message ids now start at a random point each boot, so the first request
151
+ after a reboot does not reuse the key of the boot before.
152
+ - **Test point:** the ledger counts requests per tag and records their
153
+ request ids, and `/retry-after/{seconds}` refuses a tag once with 429 and
154
+ `Retry-After`, then accepts it and records whether the retry came early.
155
+ - **sendToInternet example:** alerts once per O2 episode and at most every
156
+ 10 minutes, backs off 15 minutes after a CallMeBot refusal, tags the
157
+ startup message per boot, URL-encodes the phone number, and no longer
158
+ prints the API key. `callmebot.h` names CallMeBot's "APIKey is invalid"
159
+ reply (seen from the hardware rig) instead of calling it unrecognised.
160
+
161
+ ### Fixed
162
+
163
+ - **A gateway's own request completed inside its HTTP handler.** A bridge or
164
+ shared gateway serving its own `sendToInternet()` delivered the result from
165
+ inside the gateway handler, while its `HTTPClient`, `WiFiClient` and response
166
+ buffers were still allocated; on an ESP8266 with ~11 KB free the
167
+ application's callback could not allocate what it built (hardware rig). The
168
+ local acknowledgment now completes from the scheduler, after the handler has
169
+ freed.
170
+ - **`InternetResult::attempts` and `retryable` describe what was sent.**
171
+ `attempts` counts only a request that left the node, and a request that
172
+ never did -- refused for want of a mesh or a gateway, or whose every routing
173
+ attempt failed -- is reported safe to resend.
174
+
175
+ - **`SentBuffer::requestLength(0)` answered 1, not 0.** `buffer_length - 1`
176
+ leaves room for the terminator `toCharArray()` always writes; on an
177
+ unsigned zero it wraps to `SIZE_MAX`, `min()` then picks the message, and
178
+ a caller with no room at all is told one byte is available -- which the
179
+ method's own contract forbids ("<= the requested length") and `read()`
180
+ acts on, writing `length + 1` bytes. Found because the randomised buffer
181
+ scenario draws its length from `runif(0, ...)`: it asked for zero about
182
+ once in sixty runs and failed CI on pull requests nowhere near the buffer.
183
+
184
+ - **A node that lost its last connection waited 15 s before it scanned**
185
+ (#459). `connectToAP()` logs "scan rate set to fast" and sets a
186
+ `0.5 * SCAN_INTERVAL` period -- fifteen seconds, and the shortest of a set
187
+ of long ones; "slow" is four intervals. For a node that still has a live
188
+ connection that is soon enough. For one with none it is the whole cost of
189
+ the outage, and it is paid while its neighbours still route to it until
190
+ their own `NODE_TIMEOUT`, so unicasts addressed to it are accepted by the
191
+ sender and dropped. Caught on the hardware rig: an ESP32-C3 closed its only
192
+ uplink on a momentary nodeSync contradiction, logged the "fast" line 12 ms
193
+ later, and did not scan for exactly 15 000 ms -- 16.2 s out of the mesh,
194
+ answering an empty node list, while the scan that eventually ran found five
195
+ mesh APs at -30 to -58 dBm and associated 1.2 s later. A node with no live
196
+ connections now scans at once, once per outage (`layout::liveSubs()` is the
197
+ test, and a node that is simply alone falls back to the interval rather
198
+ than scanning back to back).
199
+
10
200
  ## [2.0.3] - 2026-09-11
11
201
 
12
202
  `sendToInternet()` fixes from one user's WhatsApp integration (#450, #452,
package/CONTRIBUTING.md CHANGED
@@ -2,13 +2,55 @@
2
2
 
3
3
  ## Branches
4
4
 
5
- - `main` holds released code. A push to `main` that carries a version bump,
6
- or whose head commit message starts with `release:`, is what tags and
7
- publishes a release (see [RELEASE_GUIDE.md](RELEASE_GUIDE.md)).
8
- - `Feat/next-release` is the integration branch for the next version. Open
9
- pull requests against it.
10
- - Work happens on short-lived feature branches (`fix/…`, `feat/…`, `docs/…`)
11
- cut from `Feat/next-release`.
5
+ | Branch | What it is | Lifetime |
6
+ |---|---|---|
7
+ | `main` | the released line. A push carrying a version-file change, or whose head commit starts with `release:`, tags and publishes (see [RELEASE_GUIDE.md](RELEASE_GUIDE.md)) | permanent |
8
+ | `release/<major>.x` | one major line — `release/3.x` while 3.0 is being built, and the same branch afterwards for patches to 2.x once `main` has moved on | as long as that major is being built or supported |
9
+ | `release/<version>` | preparing one release: the version files, the changelog date | short-lived |
10
+ | `fix/…` `feat/…` `docs/…` `ci/…` `test/…` `refactor/…` `chore/…` | one change | short-lived |
11
+
12
+ **Open pull requests against `main`** unless the change belongs to a major
13
+ line that has its own branch, in which case open them against that. This is
14
+ also what the repository does in practice: every fix since #448 was merged to
15
+ `main`.
16
+
17
+ `Feat/next-release` is retired. It was where v2 was built while `main` still
18
+ carried v1, which is exactly the job `release/<major>.x` now names; it holds
19
+ nothing `main` does not, and leaving it there sent contributors at a branch
20
+ 27 commits behind.
21
+
22
+ ### Naming
23
+
24
+ - **`type/short-slug`**, lowercase, hyphens — never underscores or capitals.
25
+ The type is one of the [Conventional Commits](https://www.conventionalcommits.org/)
26
+ types this repository already uses in commit subjects, so a branch and the
27
+ commits on it agree about what they are.
28
+ - **An issue number goes at the end** when there is one: `fix/rejoin-459`.
29
+ A slug that says only the area (`fix/critical-bugs`) tells a later reader
30
+ nothing about what was wrong.
31
+ - **No `v` in a branch name.** Tags carry it (`v2.0.3`); branches do not
32
+ (`release/2.0.4`, `release/3.x`). 2.0.2 shipped from `release/2.0.2` and
33
+ 2.0.3 from `release/v2.0.3` — pick the one without.
34
+ - **Tool-generated prefixes** (`copilot/…`, `claude/…`, `dependabot/…`) are
35
+ left as the tool makes them. They are ordinary short-lived work branches
36
+ and are deleted on merge like any other.
37
+
38
+ ### Status
39
+
40
+ A branch is deleted when its pull request merges — by whoever merges it, or
41
+ by GitHub's automatic branch deletion. Anything still on the remote is
42
+ either live work or something that was forgotten, and the two look identical
43
+ from a branch list, so:
44
+
45
+ ```bash
46
+ ./scripts/branch-status.sh # every remote branch: ahead, behind, age, unmerged work
47
+ ./scripts/branch-status.sh --stale # only the ones with nothing of their own
48
+ ```
49
+
50
+ `ahead` counts commits `main` does not have; `unmerged` counts those whose
51
+ change is not in `main` under any commit, which is the number that matters
52
+ after a squash merge. A branch with `unmerged=0` can be deleted without
53
+ losing anything.
12
54
 
13
55
  Maintainers merge quickly, often as a squash. Push every commit you describe
14
56
  before you describe it, and cut follow-up work from the merged base rather
@@ -16,7 +58,8 @@ than from a stale branch.
16
58
 
17
59
  ## Submit a pull request
18
60
 
19
- - Point the pull request at `Feat/next-release`, not `main`.
61
+ - Point the pull request at `main`, or at the major line's own branch when
62
+ the change belongs to one (see [Branches](#branches)).
20
63
  - Say what was wrong, how you know (a log, a test, a measurement), and what
21
64
  the change does about it. For anything that touches the radio, routing,
22
65
  the gateway or OTA, the evidence is a serial log or a run on the
@@ -51,9 +94,14 @@ Multi-node behaviour is tested with the external
51
94
  [painlessMesh-simulator](https://github.com/Alteriom/painlessMesh-simulator);
52
95
  scenarios for an example live under `examples/<example>/test/simulator/`
53
96
  (see `examples/basic/test/simulator/`). Radio, routing, gateway, failover
54
- and OTA behaviour is validated on the Alteriom hardware-in-the-loop farm; a
55
- maintainer runs it on a pull request by adding the `run-hil` label, and the
56
- release gate is three consecutive clean runs of the whole suite.
97
+ and OTA behaviour is validated on the Alteriom hardware-in-the-loop farm.
98
+ `.github/workflows/farm-hil.yml` sends the farm a run for **every merge to
99
+ `main` whose CI passed**, and for a pull request when a maintainer adds the
100
+ `run-hil` label; either needs the `FARM_DISPATCH_TOKEN` secret, and without
101
+ it the job says so and passes rather than pretending. A rig run is never a
102
+ release build from here -- one that may spend a real provider message is
103
+ started by a person from the farm's own workflow -- and the release gate is
104
+ three consecutive clean runs of the whole suite.
57
105
 
58
106
  ### The HTTP test point
59
107
 
@@ -17,7 +17,10 @@ lib_deps =
17
17
  esp32async/ESPAsyncTCP@^2.0.0 ; Only for ESP8266
18
18
 
19
19
  [env:esp32]
20
- platform = espressif32
20
+ ; Pinned to the ESP32 Arduino 2.x core, which compiles as gnu++11: a struct
21
+ ; with default member initializers is not an aggregate there, and this is the
22
+ ; one CI build that proves the library still builds on it (#466 review).
23
+ platform = espressif32@7.1.3
21
24
  board = esp32dev
22
25
  framework = arduino
23
26
  lib_extra_dirs = ../../ ; Load the local copy of painlessmesh. For your own example add painlessmesh to the lib_deps
@@ -17,7 +17,10 @@ lib_deps =
17
17
  esp32async/ESPAsyncTCP@^2.0.0 ; Only for ESP8266
18
18
 
19
19
  [env:esp32]
20
- platform = espressif32
20
+ ; Pinned to the ESP32 Arduino 2.x core, which compiles as gnu++11: a struct
21
+ ; with default member initializers is not an aggregate there, and this is the
22
+ ; one CI build that proves the library still builds on it (#466 review).
23
+ platform = espressif32@7.1.3
21
24
  board = esp32dev
22
25
  framework = arduino
23
26
  lib_extra_dirs = ../../
@@ -64,6 +64,55 @@ if (mesh.hasInternetConnection()) {
64
64
  }
65
65
  ```
66
66
 
67
+ ### Reading the reply: the whole result
68
+
69
+ `success` means HTTP 200, 201, 202 or 204 -- what HTTP calls a success. It
70
+ does not mean the service did what you wanted: CallMeBot, for one, answers
71
+ "Too many requests" under HTTP 201. Pass a callback that takes an
72
+ `InternetResult` to get the start of the response body as well, and decide
73
+ in your sketch:
74
+
75
+ ```cpp
76
+ #include "callmebot.h" // from this example
77
+
78
+ mesh.sendToInternet(url, "", [](const painlessmesh::InternetResult& result) {
79
+ // result.messageId, result.success, result.httpStatus, result.error,
80
+ // result.response (the body, as one line), result.retryable, result.attempts
81
+ const auto reply = callmebot::judge(result.httpStatus, result.response);
82
+ if (reply.accepted) {
83
+ Serial.println("CallMeBot queued the message");
84
+ } else {
85
+ Serial.printf("Not sent: %s -- CallMeBot said: %s\n", reply.meaning,
86
+ result.response.c_str());
87
+ }
88
+ });
89
+ ```
90
+
91
+ `callmebot.h` is part of the example, not the library: it knows CallMeBot's
92
+ wording ("Message queued", "Too many requests", "Your Account is Paused",
93
+ "APIKey is invalid")
94
+ and that its HTTP 208 has not meant a delivery. The desktop test suite checks
95
+ it against the CallMeBot-shaped test point in `test/mock-http-server/`.
96
+
97
+ ### Retries and duplicates
98
+
99
+ The library issues each request **once**, and retries only when a retry
100
+ cannot deliver it twice:
101
+
102
+ | What happened | Retried? |
103
+ |---|---|
104
+ | Connection refused, or a send failure before the request went out | yes |
105
+ | Gateway without Internet, a host that does not resolve | no -- a retry within seconds fails the same way |
106
+ | HTTP 429 or 503 (the server did not take it) | yes, not before `Retry-After` |
107
+ | Read timeout, connection lost after sending | **no** -- the server may have it |
108
+ | Any other status, including every 2xx and 500 | no |
109
+
110
+ A request that was not retried reports `retryable == false`; a transport error
111
+ says "(the request may have reached the server; not retried)". Resending is
112
+ then your call, knowing it may arrive twice. Every attempt at one call carries
113
+ the same `X-Request-Id` and `Idempotency-Key` header, so a service that honours
114
+ idempotency keys drops the copy, and a log can tell a retry from a second send.
115
+
67
116
  ### Sending JSON to REST API
68
117
 
69
118
  ```cpp
@@ -143,7 +192,8 @@ mesh.sendToInternet("https://api.callmebot.com/...", "", callback);
143
192
 
144
193
  1. Verify your Callmebot API key is correct
145
194
  2. Ensure phone number includes country code (e.g., `+1234567890`)
146
- 3. Check HTTP status code in callback (200 = success)
195
+ 3. Print `result.response` in the callback: CallMeBot says why in the body,
196
+ and HTTP 200/201 alone does not mean it sent anything
147
197
  4. URL-encode special characters in the message
148
198
 
149
199
  ### Gateway shows "no internet access" but WiFi is connected
@@ -188,7 +238,9 @@ The callback provides `httpStatus` to indicate the result:
188
238
  **FAILURE (success = false):**
189
239
  - `203 Non-Authoritative Information` - **Cached/proxied response, NOT actual delivery**
190
240
 
191
- ⚠️ **The status alone does not say whether WhatsApp got the message.** CallMeBot answers a refusal (for example "Too many requests") with HTTP 201 or HTTP 203, the same error page under both. The gateway therefore reads the start of the response body: a 2xx whose body says the service refused the request is reported as a failure, with the service's own words in the callback's `error` string, and HTTP 203 stays a failure because a proxy, not the WhatsApp API, may have answered. If your callback prints `HTTP 201: service refused the request: Oops! Too many requests...`, that is CallMeBot talking, and the message was **NOT delivered**. Likewise `HTTP 208: not a delivery the gateway can confirm: ...`: CallMeBot answers 208 to messages that never arrive (issues #450 and #452), so only 200, 201, 202 and 204 with a clean body are reported as sent, and the rest of the line is whatever the service said.
241
+ ⚠️ **The status alone does not say whether WhatsApp got the message.** CallMeBot answers a refusal (for example "Too many requests") with HTTP 201 or HTTP 203, the same error page under both, and it has answered HTTP 208 to messages that never arrived (issues #450 and #452). The library reports HTTP's meaning -- 201 is a success, 203 and 208 are not -- and hands your callback the start of the body in `result.response`. The sketch's `callmebot::judge()` reads that body: `✅ WhatsApp message queued by CallMeBot` only when CallMeBot said "Message queued", and otherwise `❌ WhatsApp message not sent` followed by what CallMeBot said.
242
+
243
+ **The sketch paces its alerts.** CallMeBot is free and rate-limited. The sketch sends one WhatsApp when O2 falls below the threshold, not one per reading, never more than one every 10 minutes per node, and waits 15 minutes after "Too many requests" or a paused account. Several nodes with the same API key share CallMeBot's limit; raise `ALERT_MIN_INTERVAL_MS` accordingly.
192
244
 
193
245
  Also replace `CLOUD_URL` at the top of the sketch: the placeholder `api.example.com` does not resolve, and until you change it the sketch skips the cloud send and says so instead of reporting `connection refused` every minute.
194
246
 
@@ -0,0 +1,139 @@
1
+ //************************************************************
2
+ // callmebot.h - what a CallMeBot WhatsApp reply means
3
+ //
4
+ // painlessMesh reports what HTTP says: the status, whether a retry could
5
+ // deliver the request twice, and the start of the response body. Whether
6
+ // WhatsApp will actually show the message is CallMeBot's business, and
7
+ // CallMeBot does not put it in the status code:
8
+ //
9
+ // * a rate-limit refusal comes back as HTTP 201 or HTTP 203 with an
10
+ // "Oops! Too many requests" page (probed 2026-09-10, painlessMesh #450);
11
+ // * HTTP 208 came back four times for messages that never arrived (#452);
12
+ // * a paused account answers with the request echoed back and, at the end,
13
+ // "Your Account is Paused ... send the word 'resume'" (#463);
14
+ // * an API key CallMeBot does not know answers HTTP 203 with the request
15
+ // echoed back and "APIKey is invalid. Please create a new one ..."
16
+ // (observed from the hardware rig, 2026-09-15);
17
+ // * a message CallMeBot accepted answers "Message queued".
18
+ //
19
+ // So the sketch reads the reply with judge() below. It is plain C++ over the
20
+ // status and the body text, with no Arduino or network calls, so the desktop
21
+ // test suite checks it (test/catch/catch_callmebot_example.cpp) against the
22
+ // same CallMeBot-shaped test point the hardware farm uses.
23
+ //
24
+ // The phrases are CallMeBot's, observed or documented at the dates above. If
25
+ // CallMeBot changes its wording, a reply lands in Verdict::Unrecognized and
26
+ // the sketch prints what the service said -- it never guesses "sent".
27
+ //************************************************************
28
+ #ifndef SENDTOINTERNET_CALLMEBOT_H
29
+ #define SENDTOINTERNET_CALLMEBOT_H
30
+
31
+ #include "painlessmesh/gateway.hpp"
32
+
33
+ namespace callmebot {
34
+
35
+ enum class Verdict {
36
+ /** CallMeBot accepted the message ("Message queued"). */
37
+ Queued,
38
+ /** "Too many requests": nothing was sent; wait before the next message. */
39
+ RateLimited,
40
+ /** The account is paused: send "resume" to the bot; resending won't help. */
41
+ AccountPaused,
42
+ /** The API key is not one CallMeBot knows: get a new one; resending won't help. */
43
+ InvalidApiKey,
44
+ /** HTTP 208: in the field this never meant a delivery, whatever the body. */
45
+ NotDelivered,
46
+ /** No HTTP reply at all; the library's error says why. */
47
+ NoReply,
48
+ /** A reply this file does not know. Treated as not sent. */
49
+ Unrecognized
50
+ };
51
+
52
+ struct Judgement {
53
+ Verdict verdict;
54
+ /** True only for Verdict::Queued. */
55
+ bool accepted;
56
+ /**
57
+ * Minimum wait before the sketch sends CallMeBot anything else, in ms.
58
+ * Non-zero after a refusal, so a sketch does not turn one refusal into a
59
+ * stream of them.
60
+ */
61
+ uint32_t holdOffMs;
62
+ /** One line for the serial log. */
63
+ const char* meaning;
64
+ };
65
+
66
+ /** How long to leave CallMeBot alone after it said "Too many requests". */
67
+ static const uint32_t RATE_LIMIT_HOLD_OFF_MS = 15UL * 60UL * 1000UL;
68
+
69
+ inline bool isHttpSuccess(uint16_t status) {
70
+ return status == 200 || status == 201 || status == 202 || status == 204;
71
+ }
72
+
73
+ /**
74
+ * Read a CallMeBot reply.
75
+ *
76
+ * @param httpStatus InternetResult::httpStatus (0 when no reply arrived)
77
+ * @param response InternetResult::response, the start of the body
78
+ */
79
+ inline Judgement judge(uint16_t httpStatus, const TSTRING& response) {
80
+ using painlessmesh::gateway::responseContains;
81
+
82
+ if (httpStatus == 0) {
83
+ return {Verdict::NoReply, false, 0,
84
+ "no reply from CallMeBot; it may or may not have received the request"};
85
+ }
86
+ // Refusals first: they arrive under success statuses too.
87
+ if (responseContains(response, "Too many requests")) {
88
+ return {Verdict::RateLimited, false, RATE_LIMIT_HOLD_OFF_MS,
89
+ "CallMeBot refused: too many requests"};
90
+ }
91
+ if (responseContains(response, "Account is Paused") ||
92
+ responseContains(response, "send the word 'resume'")) {
93
+ return {Verdict::AccountPaused, false, RATE_LIMIT_HOLD_OFF_MS,
94
+ "CallMeBot account paused: send 'resume' to the CallMeBot bot on WhatsApp"};
95
+ }
96
+ if (responseContains(response, "APIKey is invalid")) {
97
+ // Nothing a retry changes: hold off like a refusal, so a sketch with a
98
+ // wrong key does not call the API once a minute until someone notices.
99
+ return {Verdict::InvalidApiKey, false, RATE_LIMIT_HOLD_OFF_MS,
100
+ "CallMeBot rejected the API key: get a new one from CallMeBot and set WHATSAPP_APIKEY"};
101
+ }
102
+ // Before the success text: a 208 carrying "Message queued" still did not
103
+ // arrive in the field.
104
+ if (httpStatus == 208) {
105
+ return {Verdict::NotDelivered, false, 0,
106
+ "CallMeBot answered HTTP 208; such messages have not arrived"};
107
+ }
108
+ if (isHttpSuccess(httpStatus) && responseContains(response, "Message queued")) {
109
+ return {Verdict::Queued, true, 0, "CallMeBot queued the message"};
110
+ }
111
+ return {Verdict::Unrecognized, false, 0,
112
+ "CallMeBot's reply is not one this sketch recognises; not counted as sent"};
113
+ }
114
+
115
+ /**
116
+ * The URL with the API key replaced, for printing. A serial log gets pasted
117
+ * into issues; the key should not go with it.
118
+ */
119
+ inline TSTRING redactApiKey(const TSTRING& url) {
120
+ #if defined(PAINLESSMESH_BOOST)
121
+ const auto start = url.find("apikey=");
122
+ if (start == TSTRING::npos) return url;
123
+ const auto valueStart = start + 7;
124
+ auto end = url.find('&', valueStart);
125
+ if (end == TSTRING::npos) end = url.length();
126
+ return url.substr(0, valueStart) + "***" + url.substr(end);
127
+ #else
128
+ const int start = url.indexOf("apikey=");
129
+ if (start < 0) return url;
130
+ const int valueStart = start + 7;
131
+ int end = url.indexOf('&', valueStart);
132
+ if (end < 0) end = url.length();
133
+ return url.substring(0, valueStart) + "***" + url.substring(end);
134
+ #endif
135
+ }
136
+
137
+ } // namespace callmebot
138
+
139
+ #endif // SENDTOINTERNET_CALLMEBOT_H