@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.
- package/CHANGELOG.md +213 -0
- package/CONTRIBUTING.md +66 -8
- package/README.md +4 -2
- package/RELEASE_GUIDE.md +11 -1
- package/examples/sendToInternet/README.md +56 -4
- package/examples/sendToInternet/callmebot.h +139 -0
- package/examples/sendToInternet/sendToInternet.ino +166 -53
- package/library.json +1 -1
- package/library.properties +1 -1
- package/package.json +1 -1
- package/src/AlteriomPainlessMesh.h +3 -3
- package/src/arduino/wifi.hpp +179 -86
- package/src/painlessMeshSTA.cpp +38 -4
- package/src/painlessMeshSTA.h +13 -1
- package/src/painlessmesh/buffer.hpp +11 -6
- package/src/painlessmesh/callback.hpp +7 -0
- package/src/painlessmesh/gateway.hpp +649 -27
- package/src/painlessmesh/layout.hpp +16 -0
- package/src/painlessmesh/logger.hpp +14 -1
- package/src/painlessmesh/mesh.hpp +410 -103
- package/src/painlessmesh/router.hpp +17 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,219 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
`sendToInternet()` you can build a notifier on (#463). One user's WhatsApp
|
|
13
|
+
alerts through CallMeBot kept failing, and the hardware rig showed why in the
|
|
14
|
+
library: a request whose reply was slow was sent again -- one call reached the
|
|
15
|
+
server four times --, a long reply was cut before the service said what it did,
|
|
16
|
+
and a chunked reply reached the application with its transfer framing. 2.1.0
|
|
17
|
+
issues each request once, retries only what cannot arrive twice, honours
|
|
18
|
+
Retry-After, gives every attempt one request id, and hands the application the
|
|
19
|
+
whole result: status, the service's own words, whether a resend is safe, and
|
|
20
|
+
how many attempts it took. **Upgrade if a node of yours sends to the
|
|
21
|
+
Internet.**
|
|
22
|
+
|
|
23
|
+
**One behaviour change to read before upgrading:** the library no longer
|
|
24
|
+
decides from a reply's words whether a service did what you asked. A CallMeBot
|
|
25
|
+
"Too many requests" page under HTTP 201 is `success == true`, as HTTP says;
|
|
26
|
+
read the reply in your sketch with the new result callback, as
|
|
27
|
+
`examples/sendToInternet/callmebot.h` does. Wire-compatible with 2.0 nodes: the
|
|
28
|
+
new ack and request fields are optional and ignored by older nodes. Validated
|
|
29
|
+
on the six-family hardware rig, including a real WhatsApp delivered through
|
|
30
|
+
the mesh and a real CallMeBot refusal reaching the application intact.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **`sendToInternet()` no longer retries a request the server may already
|
|
35
|
+
have.** A read timeout or a connection lost after the request was sent used
|
|
36
|
+
to count as a "network error" and was retried up to three times; on the
|
|
37
|
+
hardware rig one timed-out send reached the server four times, which for a
|
|
38
|
+
message service is four messages, or one message and three refusals. The
|
|
39
|
+
gateway now tells the origin node whether the identical request may be sent
|
|
40
|
+
again (`GatewayAckPackage::retryable`, JSON `"retry"`), and says yes only
|
|
41
|
+
when it cannot have arrived -- connection refused, a failure before the
|
|
42
|
+
request went out -- or when the server said it did not take it: HTTP 429 and
|
|
43
|
+
503. A 500, 502 or 504 is no longer retried. The error of a transport
|
|
44
|
+
failure that was not retried ends "(the request may have reached the
|
|
45
|
+
server; not retried)", and resending is the application's decision.
|
|
46
|
+
Acks from gateways that predate the field keep their old reading, except
|
|
47
|
+
that a 2xx is never retried.
|
|
48
|
+
- **The library no longer judges a response body.** 2.0.3 matched
|
|
49
|
+
CallMeBot's "Too many requests" page inside the gateway and reported that
|
|
50
|
+
HTTP 201 as a failure. Whether a reply means what an application wanted is
|
|
51
|
+
that service's language, not HTTP's, so the gateway now applies HTTP's
|
|
52
|
+
meaning of the status (200, 201, 202 and 204 succeed; 203, 205-299 are
|
|
53
|
+
unverified failures) and carries the body to the application instead. **A
|
|
54
|
+
CallMeBot rate-limit refusal under HTTP 201 is therefore `success == true`
|
|
55
|
+
again at the library level.** The sendToInternet example reads CallMeBot's
|
|
56
|
+
reply itself (`examples/sendToInternet/callmebot.h`); a sketch that relied
|
|
57
|
+
on 2.0.3's check should do the same with the new result callback.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **`sendToInternet()` with an `InternetResult` callback.** Besides
|
|
62
|
+
`success`, `httpStatus` and `error`, the result carries `response` -- the
|
|
63
|
+
start of the response body as one line, on success as well as failure --
|
|
64
|
+
`retryable`, `attempts` and `messageId`. The three-argument callback is
|
|
65
|
+
unchanged. The response travels in the ack as `"resp"` and the error
|
|
66
|
+
excerpt keeps the end of a long body as well as its start, so a verdict
|
|
67
|
+
after an echo of the request survives (#463): the gateway keeps the first
|
|
68
|
+
512 and the last 256 bytes of a body, not only its start, and removes
|
|
69
|
+
chunked transfer framing -- CallMeBot answers chunked, and its reply reached
|
|
70
|
+
the application as "a6 ... Message queued ... 0". `PAINLESSMESH_HAS_INTERNET_RESULT`
|
|
71
|
+
is defined, so code that also builds against older releases can `#ifdef` it.
|
|
72
|
+
- **Retry-After.** A 429 or 503 retry waits at least as long as the server's
|
|
73
|
+
`Retry-After` (delay-seconds); a server that asks for more than 60 s gets no
|
|
74
|
+
automatic retry, and the error says when it wants the request back. The
|
|
75
|
+
request's timeout moves with the wait, so the invited retry is not timed
|
|
76
|
+
out first.
|
|
77
|
+
- **Request ids.** Every attempt at one call carries the same `X-Request-Id`
|
|
78
|
+
and `Idempotency-Key` header (`pm-<origin>-<messageId>-<nonce>`), so a
|
|
79
|
+
service that honours idempotency keys drops a copy and a log can count
|
|
80
|
+
retries. The nonce is drawn per call and travels in the request (`"nonce"`),
|
|
81
|
+
so ids stay unique after the 16-bit message-id counter wraps; a request from
|
|
82
|
+
an older node keeps `pm-<origin>-<messageId>`.
|
|
83
|
+
Message ids now start at a random point each boot, so the first request
|
|
84
|
+
after a reboot does not reuse the key of the boot before.
|
|
85
|
+
- **Test point:** the ledger counts requests per tag and records their
|
|
86
|
+
request ids, and `/retry-after/{seconds}` refuses a tag once with 429 and
|
|
87
|
+
`Retry-After`, then accepts it and records whether the retry came early.
|
|
88
|
+
- **sendToInternet example:** alerts once per O2 episode and at most every
|
|
89
|
+
10 minutes, backs off 15 minutes after a CallMeBot refusal, tags the
|
|
90
|
+
startup message per boot, URL-encodes the phone number, and no longer
|
|
91
|
+
prints the API key. `callmebot.h` names CallMeBot's "APIKey is invalid"
|
|
92
|
+
reply (seen from the hardware rig) instead of calling it unrecognised.
|
|
93
|
+
|
|
94
|
+
### Fixed
|
|
95
|
+
|
|
96
|
+
- **A gateway's own request completed inside its HTTP handler.** A bridge or
|
|
97
|
+
shared gateway serving its own `sendToInternet()` delivered the result from
|
|
98
|
+
inside the gateway handler, while its `HTTPClient`, `WiFiClient` and response
|
|
99
|
+
buffers were still allocated; on an ESP8266 with ~11 KB free the
|
|
100
|
+
application's callback could not allocate what it built (hardware rig). The
|
|
101
|
+
local acknowledgment now completes from the scheduler, after the handler has
|
|
102
|
+
freed.
|
|
103
|
+
- **`InternetResult::attempts` and `retryable` describe what was sent.**
|
|
104
|
+
`attempts` counts only a request that left the node, and a request that
|
|
105
|
+
never did -- refused for want of a mesh or a gateway, or whose every routing
|
|
106
|
+
attempt failed -- is reported safe to resend.
|
|
107
|
+
|
|
108
|
+
- **`SentBuffer::requestLength(0)` answered 1, not 0.** `buffer_length - 1`
|
|
109
|
+
leaves room for the terminator `toCharArray()` always writes; on an
|
|
110
|
+
unsigned zero it wraps to `SIZE_MAX`, `min()` then picks the message, and
|
|
111
|
+
a caller with no room at all is told one byte is available -- which the
|
|
112
|
+
method's own contract forbids ("<= the requested length") and `read()`
|
|
113
|
+
acts on, writing `length + 1` bytes. Found because the randomised buffer
|
|
114
|
+
scenario draws its length from `runif(0, ...)`: it asked for zero about
|
|
115
|
+
once in sixty runs and failed CI on pull requests nowhere near the buffer.
|
|
116
|
+
|
|
117
|
+
- **A node that lost its last connection waited 15 s before it scanned**
|
|
118
|
+
(#459). `connectToAP()` logs "scan rate set to fast" and sets a
|
|
119
|
+
`0.5 * SCAN_INTERVAL` period -- fifteen seconds, and the shortest of a set
|
|
120
|
+
of long ones; "slow" is four intervals. For a node that still has a live
|
|
121
|
+
connection that is soon enough. For one with none it is the whole cost of
|
|
122
|
+
the outage, and it is paid while its neighbours still route to it until
|
|
123
|
+
their own `NODE_TIMEOUT`, so unicasts addressed to it are accepted by the
|
|
124
|
+
sender and dropped. Caught on the hardware rig: an ESP32-C3 closed its only
|
|
125
|
+
uplink on a momentary nodeSync contradiction, logged the "fast" line 12 ms
|
|
126
|
+
later, and did not scan for exactly 15 000 ms -- 16.2 s out of the mesh,
|
|
127
|
+
answering an empty node list, while the scan that eventually ran found five
|
|
128
|
+
mesh APs at -30 to -58 dBm and associated 1.2 s later. A node with no live
|
|
129
|
+
connections now scans at once, once per outage (`layout::liveSubs()` is the
|
|
130
|
+
test, and a node that is simply alone falls back to the interval rather
|
|
131
|
+
than scanning back to back).
|
|
132
|
+
|
|
133
|
+
## [2.0.3] - 2026-09-11
|
|
134
|
+
|
|
135
|
+
`sendToInternet()` fixes from one user's WhatsApp integration (#450, #452,
|
|
136
|
+
#453), and one the hardware rig found while validating them. **Upgrade if a
|
|
137
|
+
node of yours is a bridge or relays to the Internet**: on 2.0.2 a bridge's own
|
|
138
|
+
sends were refused for 30 s after boot, a service that answered a refusal with
|
|
139
|
+
HTTP 201 was reported as delivered, a destination that does not resolve
|
|
140
|
+
stalled an ESP32 gateway on every attempt, and a bridge that rebooted as a
|
|
141
|
+
regular node swallowed every request its peers still sent it. Every fix was
|
|
142
|
+
reproduced first -- against the new HTTP test point, which CI now runs, or
|
|
143
|
+
over real TCP on the desktop -- and is covered by a row on the hardware farm.
|
|
144
|
+
|
|
145
|
+
### Fixed
|
|
146
|
+
|
|
147
|
+
- **A bridge's own `sendToInternet()` was refused for the first 30 s after
|
|
148
|
+
`initAsBridge()`** (#450). The Internet health check is armed one line after
|
|
149
|
+
`stationManual()` re-issues `WiFi.begin()`, so its first probe ran while the
|
|
150
|
+
station was still associating and failed, and the next was a full interval
|
|
151
|
+
away. Every send from the bridge in that window fell through to the mesh
|
|
152
|
+
path and was refused with "No active mesh connections" -- #445 again, with
|
|
153
|
+
a 30 s hole instead of forever. `sendToInternet()` and the retry path now
|
|
154
|
+
spend one on-demand probe per interval when the flag says no, and the
|
|
155
|
+
station's got-IP event re-probes at once on a bridge or shared gateway.
|
|
156
|
+
- **A gateway reported a refusal as delivered, and every failure as a bare
|
|
157
|
+
number** (#450, #452). The gateway decided on the HTTP status alone and
|
|
158
|
+
discarded the response body. CallMeBot's WhatsApp API answers "Too many
|
|
159
|
+
requests" with HTTP 201 and HTTP 203, the same page under both, so a refused
|
|
160
|
+
message sent through a gateway on 2.0.2 could be reported as delivered; and
|
|
161
|
+
it answers HTTP 208 to messages it never delivers, which 2.0.2 reported as
|
|
162
|
+
"Ambiguous response" with nothing to say why. The gateway now reads the
|
|
163
|
+
start of the body (bounded to 512 bytes and 250 ms). Only 200, 201, 202 and
|
|
164
|
+
204 count as delivered, and not when the body says the service refused the
|
|
165
|
+
request; every other 2xx is a failure. Every failure reaches the origin
|
|
166
|
+
node's callback with a one-line, tag-free excerpt of the body -- for example
|
|
167
|
+
`HTTP 201: service refused the request: Oops! Too many requests...` or
|
|
168
|
+
`HTTP 208: not a delivery the gateway can confirm: ...` -- and is logged at
|
|
169
|
+
ERROR level, so a sketch on the default log levels sees what the service
|
|
170
|
+
said.
|
|
171
|
+
- **A destination whose name does not resolve stalled an ESP32 gateway on
|
|
172
|
+
every attempt** (#453). A failed lookup surfaced as `connection refused`
|
|
173
|
+
after the resolver's own patience, which this library cannot bound on ESP32,
|
|
174
|
+
and every attempt -- the origin node's retries and, since the bridge fix
|
|
175
|
+
above, the bridge's own sends -- paid it again, until relayed requests timed
|
|
176
|
+
out on their origin nodes. An ESP32 gateway now resolves the host once,
|
|
177
|
+
reports `DNS lookup failed for <host>`, and refuses that host for 60 s
|
|
178
|
+
without another lookup. ESP8266 is unchanged: its core bounds the lookup by
|
|
179
|
+
the HTTP timeout. The gateway blocking budget is unchanged.
|
|
180
|
+
- **A bridge that rebooted as a regular node swallowed every Internet request
|
|
181
|
+
routed to it.** Found on the hardware rig while validating this release. A
|
|
182
|
+
bridge that reboots, crashes, loses power or is reflashed announces nothing
|
|
183
|
+
-- only a bridge stepping down in-process sends `leaving` -- so its peers
|
|
184
|
+
kept it in their bridge list for the 60 s they trust a status, and could
|
|
185
|
+
prefer it over a live bridge on RSSI. A regular node had no handler for
|
|
186
|
+
gateway requests and dropped them without a reply; each sender waited out
|
|
187
|
+
its 30 s request timeout and reported "Request timed out". Every node now
|
|
188
|
+
answers a gateway request it cannot serve with
|
|
189
|
+
`Node <id> is not an Internet gateway`; the sender forgets that node as a
|
|
190
|
+
gateway and sends again at once through the next one, without spending a
|
|
191
|
+
retry. When it knows no other gateway, the request rides the ordinary retry
|
|
192
|
+
backoff -- on the rig the live bridge was advertised half a second later --
|
|
193
|
+
and fails with `No Internet gateway available: ...` only when none appears.
|
|
194
|
+
- **Mismatched log format arguments in `routePackage()`** (CodeQL
|
|
195
|
+
`cpp/wrong-type-format-argument`). A message that failed to parse was logged
|
|
196
|
+
with its `size_t` lengths through `%d`/`%u` -- the wrong width on 64-bit
|
|
197
|
+
hosts -- and with its `DeserializationError` object passed through `%u`,
|
|
198
|
+
undefined behaviour on every platform, in the ArduinoJson 7 path every
|
|
199
|
+
current build compiles as well as the legacy one CodeQL flagged.
|
|
200
|
+
|
|
201
|
+
### Changed
|
|
202
|
+
|
|
203
|
+
- **The mock HTTP server is now the gateway test point.** It keeps a delivery
|
|
204
|
+
ledger (`GET /requests/{tag}`) and emulates CallMeBot's status quirks
|
|
205
|
+
(`GET /callmebot/whatsapp.php`, profile chosen by `apikey`). The desktop CI
|
|
206
|
+
job starts it and `PAINLESSMESH_TESTPOINT` points the suite at it, so the
|
|
207
|
+
library's delivery verdict is checked against what the service actually did,
|
|
208
|
+
not against a table of status codes. The Alteriom farm's gateway probe serves
|
|
209
|
+
the same routes.
|
|
210
|
+
- **`Mesh::sendGatewayAck()` is public and portable.** It moved from the ESP
|
|
211
|
+
gateway code into `painlessmesh::Mesh`, because every node now needs it to
|
|
212
|
+
answer a request it cannot serve.
|
|
213
|
+
- **`Log()` is format-checked in the desktop build.** The test build marks it
|
|
214
|
+
printf-like, so CI's `-Wall -Werror` rejects an argument that does not match
|
|
215
|
+
its specifier. Device builds are unchanged: on ESP-IDF 5 `uint32_t` is
|
|
216
|
+
`unsigned long`, and every `%u` of a node id would warn there.
|
|
217
|
+
- **`sendToInternet` example.** The startup WhatsApp retries every 30 s until
|
|
218
|
+
a gateway with Internet is known instead of firing once before a regular
|
|
219
|
+
node has joined, and the cloud endpoint moved to a `CLOUD_URL` define whose
|
|
220
|
+
placeholder is skipped with a message rather than reported as
|
|
221
|
+
`connection refused`.
|
|
222
|
+
|
|
10
223
|
## [2.0.2] - 2026-09-08
|
|
11
224
|
|
|
12
225
|
Two gateway defects that reached users on 2.0.1, both in code the desktop test
|
package/CONTRIBUTING.md
CHANGED
|
@@ -2,13 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
## Branches
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 `
|
|
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
|
|
@@ -55,6 +98,21 @@ and OTA behaviour is validated on the Alteriom hardware-in-the-loop farm; a
|
|
|
55
98
|
maintainer runs it on a pull request by adding the `run-hil` label, and the
|
|
56
99
|
release gate is three consecutive clean runs of the whole suite.
|
|
57
100
|
|
|
101
|
+
### The HTTP test point
|
|
102
|
+
|
|
103
|
+
`test/mock-http-server/server.py` is the controlled Internet destination for
|
|
104
|
+
`sendToInternet()` tests at every level. It answers the way real services do,
|
|
105
|
+
including the ways they get it wrong (its CallMeBot emulation returns refusals
|
|
106
|
+
as HTTP 201 and 203, as the real API does), and it keeps a delivery ledger:
|
|
107
|
+
`GET /requests/{tag}` says whether the service actually accepted a request.
|
|
108
|
+
The desktop CI job starts one and exports `PAINLESSMESH_TESTPOINT`, and
|
|
109
|
+
`catch_issue450_testpoint_semantics` makes real HTTP requests to it and
|
|
110
|
+
requires the library's verdict to match the ledger. The farm's gateway probe
|
|
111
|
+
serves the same routes with the same record shape, so a hardware row and a
|
|
112
|
+
desktop scenario are measured against one source of truth. When a gateway bug
|
|
113
|
+
comes in, add the service behaviour that exposed it to the server first, then
|
|
114
|
+
the test that fails against it.
|
|
115
|
+
|
|
58
116
|
### Adding tests for new features
|
|
59
117
|
|
|
60
118
|
1. **Unit tests**: add to `test/catch/` for new components.
|
package/README.md
CHANGED
|
@@ -596,9 +596,11 @@ These are the message types used by applications built on painlessMesh:
|
|
|
596
596
|
- **Event Coordination** - Synchronized displays, distributed processing
|
|
597
597
|
- **Bridge Networks** - Connect mesh to WiFi/Internet/MQTT - [📖 Bridge Guide](BRIDGE_TO_INTERNET.md)
|
|
598
598
|
|
|
599
|
-
## Latest Release: v2.0.
|
|
599
|
+
## Latest Release: v2.0.3 (September 11, 2026)
|
|
600
600
|
|
|
601
|
-
|
|
601
|
+
**`sendToInternet()` fixes — upgrade if any node of yours is a bridge or relays to the Internet.** On 2.0.2 a bridge's own sends were refused with "No active mesh connections" for the first 30 s after `initAsBridge()`, because the health check's first probe ran while the station was still associating. The gateway also decided delivery on the HTTP status alone: CallMeBot answers a refusal with HTTP 201, so a message it refused could be reported as sent, and every failure reached the origin node as a bare number. The gateway now reads the start of the response body, counts only 200/201/202/204 without a refusing body as delivered, and hands every failure to your callback with the service's own words. A destination whose name does not resolve no longer stalls an ESP32 gateway on every attempt: it is refused for 60 s after one failed lookup. And a bridge that reboots, crashes or is reflashed as a regular node no longer swallows the requests its peers still send it for a minute: it answers that it is not a gateway, and the sender moves to the next one at once.
|
|
602
|
+
|
|
603
|
+
**2.0.2 — two gateway fixes.** On 2.0.1 a bridge could not reach the Internet through its own uplink at all (`initAsBridge()` never started the health checker that `sendToInternet()`'s local path depends on, so a bridge with no peer yet failed with "No active mesh connections"), and any request that failed *below* HTTP — refused, unresolvable, timed out — was reported to the origin node as `HTTP 65535`, a truncated `-1`, which also stopped it from being retried. Both are now asserted on the hardware rig, including a bridge with no mesh peer at all.
|
|
602
604
|
|
|
603
605
|
**2.0.1 — a packaging fix over 2.0.0, no library behaviour changed.** 2.0.0's installation instructions named a PlatformIO package that has no 2.0.0 (`alteriom/…` stops at 1.10.0; releases go out under `sparck75`), and its GitHub release carried no library archive because the upload was refused by an immutable release.
|
|
604
606
|
|
package/RELEASE_GUIDE.md
CHANGED
|
@@ -38,13 +38,23 @@ These files must always contain the same semantic version:
|
|
|
38
38
|
- `library.properties`
|
|
39
39
|
- `library.json`
|
|
40
40
|
- `package.json`
|
|
41
|
+
- `package-lock.json` (the root `version` and `packages[""].version`)
|
|
42
|
+
- `src/AlteriomPainlessMesh.h` (the version string and the MAJOR/MINOR/PATCH
|
|
43
|
+
defines a sketch compiles against)
|
|
44
|
+
- `doxygen/Doxyfile` (`PROJECT_NUMBER`, the number on every generated API page)
|
|
41
45
|
|
|
42
|
-
Use the repository script to change them together
|
|
46
|
+
Use the repository script to change them together; it updates all six and then
|
|
47
|
+
verifies they agree:
|
|
43
48
|
|
|
44
49
|
```bash
|
|
45
50
|
./scripts/bump-version.sh patch
|
|
51
|
+
./scripts/bump-version.sh patch 2.0.3 --yes # no prompt, for CI or an agent
|
|
46
52
|
```
|
|
47
53
|
|
|
54
|
+
`jq` is used when it is installed and awk otherwise, and both paths change only
|
|
55
|
+
the package's own version — never a dependency range. `test/ci/test_bump_version.sh`
|
|
56
|
+
holds that, and the Code Quality job runs it.
|
|
57
|
+
|
|
48
58
|
You may use `minor`, `major`, or an explicit version when the release plan
|
|
49
59
|
requires it. Version 2.0 patch releases must remain wire-compatible with the
|
|
50
60
|
2.0 protocol.
|
|
@@ -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.
|
|
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
|
|
@@ -187,10 +237,12 @@ The callback provides `httpStatus` to indicate the result:
|
|
|
187
237
|
|
|
188
238
|
**FAILURE (success = false):**
|
|
189
239
|
- `203 Non-Authoritative Information` - **Cached/proxied response, NOT actual delivery**
|
|
190
|
-
- `4xx` - Client error (bad request, unauthorized, not found, etc.)
|
|
191
|
-
- `5xx` - Server error (service unavailable, gateway timeout, etc.)
|
|
192
240
|
|
|
193
|
-
⚠️ **
|
|
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.
|
|
244
|
+
|
|
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
|
|
|
195
247
|
## Files
|
|
196
248
|
|
|
@@ -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
|