Reconnect with back-off on every disconnect reason and log it
The session is opened again whatever AsyncMqttClient gives as the reason, after 1 s doubling to 60 s (AlexaReconnectBackoff, reset when a session opens), and only while Wi-Fi is up. 1.1.0 retried a lost TCP connection every 5 s and nothing else, silently: a refused password or a broker that was restarting left the board offline until a reset. Every disconnect prints its reason in words and the wait before the next attempt. The first connect waits for Wi-Fi; a connect without an answer after 30 s counts as failed, because the MQTT client reports nothing when no TCP connection was made. Keep-alive 30 s. basicLight: RAM 34,452 -> 34,476 B, flash 337,461 -> 338,281 B; host tests 41 -> 45. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
cb5c9876fe
commit
2ed52c8721
6 changed files with 194 additions and 23 deletions
|
|
@ -137,7 +137,7 @@ An `ActionMapping` takes an optional third argument, the directive payload as JS
|
||||||
|
|
||||||
Everything goes over MQTT (port 1883 of `alex2mqtt.stormysdream.club`); the library makes no HTTP requests.
|
Everything goes over MQTT (port 1883 of `alex2mqtt.stormysdream.club`); the library makes no HTTP requests.
|
||||||
|
|
||||||
- **Session.** `begin()` starts SNTP (`pool.ntp.org`, `time.nist.gov`) and returns; `loop()` opens the MQTT session once the clock is set, or after 5 s without an answer, and subscribes to `<root>/discover` and `<root>/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to <topic>` means that the root topic is not the one of the account. A sketch that sets the clock itself (its own `configTime()` with a time zone, an RTC) calls `alexClient.setTimeSource(false)` before `begin()`. The board has to reach an NTP server: DNS for the two names and outbound UDP port 123. Without the time of day the session still opens, the reports carry a `timeOfSample` in 1970, and the library prints `[Alex2ESP] error: the clock is not set, ...` when it connects and then at most once a minute while reports are sent.
|
- **Session.** `begin()` starts SNTP (`pool.ntp.org`, `time.nist.gov`) and returns; `loop()` opens the MQTT session once Wi-Fi is up and the clock is set, or after 5 s of Wi-Fi without an answer, and subscribes to `<root>/discover` and `<root>/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to <topic>` means that the root topic is not the one of the account. A session that ends or cannot be opened prints `[Alex2ESP] error: disconnected: <reason>; next attempt in N s` and is opened again after 1 s, then 2 s, 4 s and so on up to once a minute, for as long as Wi-Fi is up; the wait starts at 1 s again once a session is open. `the broker refused the username or the password` is the reason to look for when a board never shows up in Alexa. A sketch that sets the clock itself (its own `configTime()` with a time zone, an RTC) calls `alexClient.setTimeSource(false)` before `begin()`. The board has to reach an NTP server: DNS for the two names and outbound UDP port 123. Without the time of day the session still opens, the reports carry a `timeOfSample` in 1970, and the library prints `[Alex2ESP] error: the clock is not set, ...` when it connects and then at most once a minute while reports are sent.
|
||||||
- **Discovery.** On `<root>/discover` the library answers with one discovery object per device on `<root>/discover_r`. The backend accepts one endpoint object per message and collects everything that arrives within 1 s for Alexa's discovery answer (up to 5 s for its proactive AddOrUpdate push), so all devices are published back to back from the next `loop()`. Each object sits on the heap (about 1 KB) until the MQTT client has sent it; when the client cannot take another one (free heap under 4 KB), the library prints `[Alex2ESP] discovery deferred at <endpointId>` and sends the rest from `loop()` as the queue drains, for up to 5 s after the request. `[Alex2ESP] error: discovery gave up: N device(s) not announced` means those devices missed this answer - on the backend's proactive discovery that can remove them from Alexa until the next one.
|
- **Discovery.** On `<root>/discover` the library answers with one discovery object per device on `<root>/discover_r`. The backend accepts one endpoint object per message and collects everything that arrives within 1 s for Alexa's discovery answer (up to 5 s for its proactive AddOrUpdate push), so all devices are published back to back from the next `loop()`. Each object sits on the heap (about 1 KB) until the MQTT client has sent it; when the client cannot take another one (free heap under 4 KB), the library prints `[Alex2ESP] discovery deferred at <endpointId>` and sends the rest from `loop()` as the queue drains, for up to 5 s after the request. `[Alex2ESP] error: discovery gave up: N device(s) not announced` means those devices missed this answer - on the backend's proactive discovery that can remove them from Alexa until the next one.
|
||||||
- **Directives.** The directive arrives as JSON on `<root>/<endpointId>/alexaDirective`. A directive larger than one TCP segment arrives in fragments, which are put together in one heap block that exists only until `loop()` has parsed it. `loop()` then fires `ReportState` or `Event` (and `DirectiveReceived`, if registered) with the directive: `directive["header"]`, `directive["endpoint"]`, `directive["payload"]`. Every call of `loop()` handles one directive, in the order of arrival. Up to eight directives wait for it: Alexa sends a group command ("turn off the kitchen") as one directive per endpoint, and they arrive faster than a busy sketch calls `loop()`. A directive that arrives twice (a broker that mirrors its topics delivers every message twice) is handled once; the repeat is recognised while it arrives and takes no place among the waiting ones.
|
- **Directives.** The directive arrives as JSON on `<root>/<endpointId>/alexaDirective`. A directive larger than one TCP segment arrives in fragments, which are put together in one heap block that exists only until `loop()` has parsed it. `loop()` then fires `ReportState` or `Event` (and `DirectiveReceived`, if registered) with the directive: `directive["header"]`, `directive["endpoint"]`, `directive["payload"]`. Every call of `loop()` handles one directive, in the order of arrival. Up to eight directives wait for it: Alexa sends a group command ("turn off the kitchen") as one directive per endpoint, and they arrive faster than a busy sketch calls `loop()`. A directive that arrives twice (a broker that mirrors its topics delivers every message twice) is handled once; the repeat is recognised while it arrives and takes no place among the waiting ones.
|
||||||
- **Reports.** `send()` publishes the report on `<root>/<endpointId>/alexaResponce` at once. The backend waits 7 s for it, so answer from the event handler. Every property carries the board's UTC time as `timeOfSample`.
|
- **Reports.** `send()` publishes the report on `<root>/<endpointId>/alexaResponce` at once. The backend waits 7 s for it, so answer from the event handler. Every property carries the board's UTC time as `timeOfSample`.
|
||||||
|
|
@ -251,14 +251,15 @@ Behaviour changes:
|
||||||
- Discovery is answered from `loop()`, not inside the MQTT callback. A discovery object over 3071 bytes (`ALEX2ESP_MAX_MESSAGE`) is refused with an error; the other devices are still announced.
|
- Discovery is answered from `loop()`, not inside the MQTT callback. A discovery object over 3071 bytes (`ALEX2ESP_MAX_MESSAGE`) is refused with an error; the other devices are still announced.
|
||||||
- `getState()` stays `INITIALIZED` until the first connect, and becomes `CONNECTED` when the broker has acknowledged both subscriptions (1.1.0: the first of them). A subscription that the broker refuses prints an error that names the topic and what to check, the root topic passed to `begin()`; the state stays `SUBSCRIBING`. When the MQTT client does not take a subscription, the session is closed and the next one subscribes again.
|
- `getState()` stays `INITIALIZED` until the first connect, and becomes `CONNECTED` when the broker has acknowledged both subscriptions (1.1.0: the first of them). A subscription that the broker refuses prints an error that names the topic and what to check, the root topic passed to `begin()`; the state stays `SUBSCRIBING`. When the MQTT client does not take a subscription, the session is closed and the next one subscribes again.
|
||||||
- Serial output goes through one log with levels. `alexClient.setLogLevel()` takes `AlexaLogLevel::NONE`, `ERROR`, `INFO` (the default) or `DEBUG`; `-DALEX2ESP_LOG_MAX=<0..3>` in `build_flags` sets the highest level that is compiled in (default 2, `INFO`). This replaces the `Alex2ESP_DEBUG` define inside `AlexaUtils.cpp`. 1.1.0 printed the memory figures for every MQTT message and the whole directive, correlation token included, for every directive; both are gone. A sketch or a build flag that defines `DEBUG`, `ERROR` or `INFO` as a macro (`#define DEBUG 1`, `-DDEBUG`) still compiles: the library sets those macros aside where it declares its levels and passes a level by its number everywhere else.
|
- Serial output goes through one log with levels. `alexClient.setLogLevel()` takes `AlexaLogLevel::NONE`, `ERROR`, `INFO` (the default) or `DEBUG`; `-DALEX2ESP_LOG_MAX=<0..3>` in `build_flags` sets the highest level that is compiled in (default 2, `INFO`). This replaces the `Alex2ESP_DEBUG` define inside `AlexaUtils.cpp`. 1.1.0 printed the memory figures for every MQTT message and the whole directive, correlation token included, for every directive; both are gone. A sketch or a build flag that defines `DEBUG`, `ERROR` or `INFO` as a macro (`#define DEBUG 1`, `-DDEBUG`) still compiles: the library sets those macros aside where it declares its levels and passes a level by its number everywhere else.
|
||||||
|
- The session is opened again whatever ended it, and the reason is printed in words: `disconnected: the broker refused the username or the password, check the two passed to begin(); next attempt in 1 s`. 1.1.0 reconnected every 5 s after a lost TCP connection only and printed nothing; a board with a wrong password, or one that met the broker while it was restarting, stayed offline until it was reset. The wait is 1 s after a session ended and doubles with every attempt that fails, up to 60 s. Nothing is tried while Wi-Fi is down, and the first connect waits for Wi-Fi as well (`waiting for Wi-Fi: ...` is printed once). A connect that has no answer after 30 s counts as failed. The MQTT keep-alive is 30 s (1.1.0: the 15 s of the MQTT client).
|
||||||
- `getDevice()` before `begin()` prints an error: the device would have no root topic. A second `begin()` is ignored with an error.
|
- `getDevice()` before `begin()` prints an error: the device would have no root topic. A second `begin()` is ignored with an error.
|
||||||
- A report is sent through the client that created its device. An `AlexaDevice` or an `AlexaStatusMessage` that a sketch constructs itself has no client: `send()` returns `false` and prints `report for <endpointId> not sent: its device was not created by getDevice()`, where 1.1.0 queued the report. Use `getDevice()` and `buildStatusMessage()`. Both constructors take the client as an optional last argument, an `AlexaTransport*`.
|
- A report is sent through the client that created its device. An `AlexaDevice` or an `AlexaStatusMessage` that a sketch constructs itself has no client: `send()` returns `false` and prints `report for <endpointId> not sent: its device was not created by getDevice()`, where 1.1.0 queued the report. Use `getDevice()` and `buildStatusMessage()`. Both constructors take the client as an optional last argument, an `AlexaTransport*`.
|
||||||
- New: `Alex2ESP::setLogLevel()`, `Alex2ESP::setTimeSource()`, `AlexaDevice::hasEndpointId()`, `AlexaLog`, `AlexaSendResult`, and `AlexaTransport`, the interface a device sends and stamps its reports through. `Alex2ESP` implements it with `publish(topic, document)`, which sends a `JsonDocument` on the session of the library under the same checks as a report and returns an `AlexaSendResult`, and `timestamp(buffer, size)`, which writes the current time as `timeOfSample` has it.
|
- New: `Alex2ESP::setLogLevel()`, `Alex2ESP::setTimeSource()`, `AlexaDevice::hasEndpointId()`, `AlexaLog`, `AlexaSendResult`, and `AlexaTransport`, the interface a device sends and stamps its reports through. `Alex2ESP` implements it with `publish(topic, document)`, which sends a `JsonDocument` on the session of the library under the same checks as a report and returns an `AlexaSendResult`, and `timestamp(buffer, size)`, which writes the current time as `timeOfSample` has it.
|
||||||
- Removed: the queues and buffers of `AlexaUtils` (`enqueue`, `dequeue`, `dequeueVals`, `enqueueReceive`, `dequeueReceive`, `isQueueEmpty`, `isQueueFull`, `isReceiveQueueEmpty`, `isReceiveQueueFull`, `receivePayload`, `nextMessageId`) and its `log`/`logln`, which printed nothing unless the library was edited; `AlexaUtils::printMemoryInfo()` stays. `MAX_STATUS_REPORT_SIZE` (the limit is `ALEX2ESP_MAX_MESSAGE`). The library no longer includes `ESP8266HTTPClient`.
|
- Removed: the queues and buffers of `AlexaUtils` (`enqueue`, `dequeue`, `dequeueVals`, `enqueueReceive`, `dequeueReceive`, `isQueueEmpty`, `isQueueFull`, `isReceiveQueueEmpty`, `isReceiveQueueFull`, `receivePayload`, `nextMessageId`) and its `log`/`logln`, which printed nothing unless the library was edited; `AlexaUtils::printMemoryInfo()` stays. `MAX_STATUS_REPORT_SIZE` (the limit is `ALEX2ESP_MAX_MESSAGE`). The library no longer includes `ESP8266HTTPClient`.
|
||||||
|
|
||||||
Memory: `examples/basicLight.cpp` for a D1 mini takes 34,452 bytes of static RAM (1.1.0: 52,768) and 337,461 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). The static RAM was the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients. SNTP and the time stamp are 1.8 KB of the flash figure.
|
Memory: `examples/basicLight.cpp` for a D1 mini takes 34,476 bytes of static RAM (1.1.0: 52,768) and 338,281 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). The static RAM was the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients. SNTP and the time stamp are 1.8 KB of the flash figure.
|
||||||
|
|
||||||
Tests: `pio test -e native` in the repository runs 41 host tests of the receive and publish logic (reassembly of fragments, the directives that wait for `loop()`, repeated directives, the size limits, a heap without room, topics, time stamps). No board is needed.
|
Tests: `pio test -e native` in the repository runs 45 host tests of the receive and publish logic (reassembly of fragments, the directives that wait for `loop()`, repeated directives, the size limits, a heap without room, the wait between reconnects, topics, time stamps). No board is needed.
|
||||||
|
|
||||||
Packaging: `library.json`, `library.properties` and the `softwareVersion` and `firmwareVersion` that a device reports in discovery say 1.2.0; the library has the number in one place, `ALEX2ESP_VERSION` in `src/AlexaVersion.h`. The `platformio.ini` of the repository is for the host tests; a sketch does not need it.
|
Packaging: `library.json`, `library.properties` and the `softwareVersion` and `firmwareVersion` that a device reports in discovery say 1.2.0; the library has the number in one place, `ALEX2ESP_VERSION` in `src/AlexaVersion.h`. The `platformio.ini` of the repository is for the host tests; a sketch does not need it.
|
||||||
|
|
||||||
|
|
|
||||||
106
src/Alex2ESP.cpp
106
src/Alex2ESP.cpp
|
|
@ -9,6 +9,11 @@
|
||||||
*/
|
*/
|
||||||
#include "Alex2ESP.h"
|
#include "Alex2ESP.h"
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#ifdef ESP8266
|
||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
#else
|
||||||
|
#include <WiFi.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
@ -17,6 +22,10 @@ namespace
|
||||||
const char NTP_SERVER_1[] = "pool.ntp.org";
|
const char NTP_SERVER_1[] = "pool.ntp.org";
|
||||||
const char NTP_SERVER_2[] = "time.nist.gov";
|
const char NTP_SERVER_2[] = "time.nist.gov";
|
||||||
|
|
||||||
|
// The broker closes a session that was silent for one and a half times this long, and the board notices a
|
||||||
|
// broker that is gone after the same time
|
||||||
|
const uint16_t KEEP_ALIVE_S = 30;
|
||||||
|
|
||||||
const uint8_t SUBSCRIPTION_REFUSED = 0x80; // Return code of a SUBACK for a subscription the broker denies
|
const uint8_t SUBSCRIPTION_REFUSED = 0x80; // Return code of a SUBACK for a subscription the broker denies
|
||||||
|
|
||||||
// Room the MQTT client needs on top of topic and payload for the packet it builds (headers, its own object)
|
// Room the MQTT client needs on top of topic and payload for the packet it builds (headers, its own object)
|
||||||
|
|
@ -30,6 +39,30 @@ namespace
|
||||||
return ESP.getMaxAllocHeap(); // ESP32: untested
|
return ESP.getMaxAllocHeap(); // ESP32: untested
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the session ended or was not opened, and what to check where the sketch can do something about it
|
||||||
|
PGM_P disconnectReasonText(AsyncMqttClientDisconnectReason reason)
|
||||||
|
{
|
||||||
|
switch (reason)
|
||||||
|
{
|
||||||
|
case AsyncMqttClientDisconnectReason::TCP_DISCONNECTED:
|
||||||
|
return PSTR("the broker cannot be reached or the connection was lost");
|
||||||
|
case AsyncMqttClientDisconnectReason::MQTT_MALFORMED_CREDENTIALS:
|
||||||
|
case AsyncMqttClientDisconnectReason::MQTT_NOT_AUTHORIZED:
|
||||||
|
return PSTR("the broker refused the username or the password, check the two passed to begin()");
|
||||||
|
case AsyncMqttClientDisconnectReason::MQTT_SERVER_UNAVAILABLE:
|
||||||
|
return PSTR("the broker is not available");
|
||||||
|
case AsyncMqttClientDisconnectReason::MQTT_IDENTIFIER_REJECTED:
|
||||||
|
return PSTR("the broker refused the client id");
|
||||||
|
case AsyncMqttClientDisconnectReason::MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
|
||||||
|
return PSTR("the broker does not accept MQTT 3.1.1");
|
||||||
|
case AsyncMqttClientDisconnectReason::ESP8266_NOT_ENOUGH_SPACE:
|
||||||
|
return PSTR("no memory for the connection");
|
||||||
|
case AsyncMqttClientDisconnectReason::TLS_BAD_FINGERPRINT:
|
||||||
|
return PSTR("the certificate of the broker has another fingerprint");
|
||||||
|
}
|
||||||
|
return PSTR("the MQTT client gave no reason");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Alex2ESP::Alex2ESP()
|
Alex2ESP::Alex2ESP()
|
||||||
|
|
@ -37,10 +70,11 @@ Alex2ESP::Alex2ESP()
|
||||||
state(Alex2ESPState::UNINITIALIZED),
|
state(Alex2ESPState::UNINITIALIZED),
|
||||||
disconnectReason(AsyncMqttClientDisconnectReason::TCP_DISCONNECTED),
|
disconnectReason(AsyncMqttClientDisconnectReason::TCP_DISCONNECTED),
|
||||||
useSntp(true),
|
useSntp(true),
|
||||||
beginTime(0),
|
clockWaitStarted(0),
|
||||||
|
linkWaitLogged(false),
|
||||||
clockWarned(false),
|
clockWarned(false),
|
||||||
lastClockWarning(0),
|
lastClockWarning(0),
|
||||||
lastReconnectTime(0),
|
waitingSince(0),
|
||||||
discoverSubscription(0),
|
discoverSubscription(0),
|
||||||
directiveSubscription(0),
|
directiveSubscription(0),
|
||||||
subscriptionsPending(0),
|
subscriptionsPending(0),
|
||||||
|
|
@ -88,9 +122,10 @@ void Alex2ESP::begin(const char *username, const char *password, const char *roo
|
||||||
// Configure MQTT client
|
// Configure MQTT client
|
||||||
mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
|
mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
|
||||||
mqttClient.setCredentials(username, password);
|
mqttClient.setCredentials(username, password);
|
||||||
|
mqttClient.setKeepAlive(KEEP_ALIVE_S);
|
||||||
|
|
||||||
// loop() connects: see connectWhenClockIsSet()
|
// loop() connects: see connectWhenClockIsSet()
|
||||||
beginTime = millis();
|
clockWaitStarted = millis();
|
||||||
state = Alex2ESPState::INITIALIZED;
|
state = Alex2ESPState::INITIALIZED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,42 +183,70 @@ AlexaDevice *Alex2ESP::findDevice(const char *endpointId, size_t length)
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first connect waits until the clock is set, for CLOCK_WAIT_MS at most: a report sent before SNTP has answered
|
// The first connect waits for Wi-Fi and then until the clock is set, for CLOCK_WAIT_MS at most: a report sent before
|
||||||
// would carry a time of sample in 1970.
|
// SNTP has answered would carry a time of sample in 1970.
|
||||||
void Alex2ESP::connectWhenClockIsSet()
|
void Alex2ESP::connectWhenClockIsSet()
|
||||||
{
|
{
|
||||||
if (state != Alex2ESPState::INITIALIZED)
|
if (state != Alex2ESPState::INITIALIZED)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (WiFi.status() != WL_CONNECTED)
|
||||||
|
{
|
||||||
|
if (!linkWaitLogged)
|
||||||
|
{
|
||||||
|
linkWaitLogged = true;
|
||||||
|
ALEX2ESP_LOGI("waiting for Wi-Fi: the sketch has to join a network with WiFi.begin()");
|
||||||
|
}
|
||||||
|
// SNTP cannot answer without a link, so the wait for the clock starts when the link is up
|
||||||
|
clockWaitStarted = millis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!AlexaBridgeLogic::clockIsSet(time(nullptr)))
|
if (!AlexaBridgeLogic::clockIsSet(time(nullptr)))
|
||||||
{
|
{
|
||||||
if (millis() - beginTime < CLOCK_WAIT_MS)
|
if (millis() - clockWaitStarted < CLOCK_WAIT_MS)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
warnAboutClock();
|
warnAboutClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Alex2ESP::connect()
|
||||||
|
{
|
||||||
state = Alex2ESPState::CONNECTING;
|
state = Alex2ESPState::CONNECTING;
|
||||||
lastReconnectTime = millis();
|
waitingSince = millis();
|
||||||
mqttClient.connect();
|
mqttClient.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opens the session again after it has ended or could not be opened, whatever the reason: a password that was
|
||||||
|
// refused is accepted once the account has been repaired, and a broker that was restarting is back later.
|
||||||
void Alex2ESP::handleMqttReconnection()
|
void Alex2ESP::handleMqttReconnection()
|
||||||
{
|
{
|
||||||
if (state == Alex2ESPState::UNINITIALIZED || state == Alex2ESPState::INITIALIZED)
|
if (state == Alex2ESPState::CONNECTING && millis() - waitingSince >= CONNECT_TIMEOUT_MS)
|
||||||
|
{
|
||||||
|
// The MQTT client reports the end of an attempt only when it had a TCP connection to close. Without one
|
||||||
|
// (the name of the broker did not resolve) it would stay in its connecting state and ignore every connect().
|
||||||
|
mqttClient.disconnect(true);
|
||||||
|
if (state == Alex2ESPState::CONNECTING)
|
||||||
|
{
|
||||||
|
onMqttDisconnect(AsyncMqttClientDisconnectReason::TCP_DISCONNECTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != Alex2ESPState::DISCONNECTED || !backoff.due(millis(), waitingSince))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mqttClient.connected() && disconnectReason == AsyncMqttClientDisconnectReason::TCP_DISCONNECTED)
|
if (WiFi.status() != WL_CONNECTED)
|
||||||
{
|
{
|
||||||
if (millis() - lastReconnectTime > RECONNECT_INTERVAL_MS)
|
// An attempt without a link fails at once and would only lengthen the wait
|
||||||
{
|
return;
|
||||||
lastReconnectTime = millis();
|
|
||||||
mqttClient.connect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
backoff.attempt();
|
||||||
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Alex2ESP::onMqttConnect(bool sessionPresent)
|
void Alex2ESP::onMqttConnect(bool sessionPresent)
|
||||||
|
|
@ -201,6 +264,7 @@ void Alex2ESP::onMqttConnect(bool sessionPresent)
|
||||||
mqttClient.disconnect();
|
mqttClient.disconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
backoff.reset();
|
||||||
ALEX2ESP_LOGI("connected to %s, subscribing", MQTT_SERVER);
|
ALEX2ESP_LOGI("connected to %s, subscribing", MQTT_SERVER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,8 +294,20 @@ void Alex2ESP::onMqttDisconnect(AsyncMqttClientDisconnectReason reason)
|
||||||
{
|
{
|
||||||
state = Alex2ESPState::DISCONNECTED;
|
state = Alex2ESPState::DISCONNECTED;
|
||||||
disconnectReason = reason;
|
disconnectReason = reason;
|
||||||
|
waitingSince = millis();
|
||||||
directives.cancelArrival();
|
directives.cancelArrival();
|
||||||
ALEX2ESP_LOGI("disconnected (reason %u)", (unsigned)reason);
|
|
||||||
|
char words[96];
|
||||||
|
strncpy_P(words, disconnectReasonText(reason), sizeof(words) - 1);
|
||||||
|
words[sizeof(words) - 1] = '\0';
|
||||||
|
if (WiFi.status() == WL_CONNECTED)
|
||||||
|
{
|
||||||
|
ALEX2ESP_LOGE("disconnected: %s; next attempt in %u s", words, (unsigned)(backoff.wait() / 1000));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ALEX2ESP_LOGE("disconnected: %s; next attempt when Wi-Fi is back", words);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs in the network context, once per fragment of a message. It only takes notes: loop() answers a Discover and
|
// Runs in the network context, once per fragment of a message. It only takes notes: loop() answers a Discover and
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ public:
|
||||||
|
|
||||||
// Begin function for initialization: MQTT username, MQTT password, root topic (the same order as alex2node).
|
// Begin function for initialization: MQTT username, MQTT password, root topic (the same order as alex2node).
|
||||||
// The username and the password are not copied: they have to stay valid for as long as the client is used.
|
// The username and the password are not copied: they have to stay valid for as long as the client is used.
|
||||||
// Starts SNTP; loop() opens the MQTT session once the clock is set, or after 5 s without an answer.
|
// Starts SNTP; loop() opens the MQTT session once Wi-Fi is up and the clock is set, or after 5 s of Wi-Fi
|
||||||
|
// without an answer. A session that ends or is refused is opened again after 1 s, then 2 s, 4 s ... up to once
|
||||||
|
// a minute, while Wi-Fi is up; the reason is printed and returned by getDisconnectReason().
|
||||||
void begin(const char *username, const char *password, const char *rootTopic);
|
void begin(const char *username, const char *password, const char *rootTopic);
|
||||||
Alex2ESPState getState() const;
|
Alex2ESPState getState() const;
|
||||||
|
|
||||||
|
|
@ -74,7 +76,7 @@ public:
|
||||||
private:
|
private:
|
||||||
static const unsigned long CLOCK_WAIT_MS = 5000; // How long the first connect waits for SNTP
|
static const unsigned long CLOCK_WAIT_MS = 5000; // How long the first connect waits for SNTP
|
||||||
static const unsigned long CLOCK_WARNING_MS = 60000; // How often an unset clock is reported while it is used
|
static const unsigned long CLOCK_WARNING_MS = 60000; // How often an unset clock is reported while it is used
|
||||||
static const unsigned long RECONNECT_INTERVAL_MS = 5000;
|
static const unsigned long CONNECT_TIMEOUT_MS = 30000; // How long a connect may stay without an answer
|
||||||
static const unsigned long DISCOVERY_WINDOW_MS = 5000; // How long the backend keeps collecting a discovery answer
|
static const unsigned long DISCOVERY_WINDOW_MS = 5000; // How long the backend keeps collecting a discovery answer
|
||||||
static const unsigned long DISCOVERY_RETRY_MS = 20; // Pause before a refused discovery publish is tried again
|
static const unsigned long DISCOVERY_RETRY_MS = 20; // Pause before a refused discovery publish is tried again
|
||||||
|
|
||||||
|
|
@ -89,10 +91,12 @@ private:
|
||||||
Alex2ESPState state;
|
Alex2ESPState state;
|
||||||
AsyncMqttClientDisconnectReason disconnectReason;
|
AsyncMqttClientDisconnectReason disconnectReason;
|
||||||
bool useSntp;
|
bool useSntp;
|
||||||
unsigned long beginTime; // millis() when begin() ran
|
unsigned long clockWaitStarted; // millis() when begin() ran, or when Wi-Fi was last seen down before the first connect
|
||||||
|
bool linkWaitLogged; // The wait for Wi-Fi before the first connect has been reported
|
||||||
bool clockWarned; // The unset clock has been reported
|
bool clockWarned; // The unset clock has been reported
|
||||||
unsigned long lastClockWarning; // millis() of that report
|
unsigned long lastClockWarning; // millis() of that report
|
||||||
unsigned long lastReconnectTime;
|
unsigned long waitingSince; // millis() of the connect while CONNECTING, of the disconnect while DISCONNECTED
|
||||||
|
AlexaReconnectBackoff backoff; // The wait before the next connect
|
||||||
uint16_t discoverSubscription; // Packet ids of the two SUBSCRIBEs, to match their acknowledgements
|
uint16_t discoverSubscription; // Packet ids of the two SUBSCRIBEs, to match their acknowledgements
|
||||||
uint16_t directiveSubscription;
|
uint16_t directiveSubscription;
|
||||||
uint8_t subscriptionsPending;
|
uint8_t subscriptionsPending;
|
||||||
|
|
@ -116,6 +120,7 @@ private:
|
||||||
|
|
||||||
//loop processing function
|
//loop processing function
|
||||||
void connectWhenClockIsSet();
|
void connectWhenClockIsSet();
|
||||||
|
void connect();
|
||||||
void warnAboutClock();
|
void warnAboutClock();
|
||||||
void handleMqttReconnection();
|
void handleMqttReconnection();
|
||||||
void continueDiscovery();
|
void continueDiscovery();
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,11 @@ bool AlexaRecentIds::seenBefore(const char *id)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AlexaReconnectBackoff::attempt()
|
||||||
|
{
|
||||||
|
waitMs = (waitMs >= LONGEST_WAIT_MS / 2) ? LONGEST_WAIT_MS : waitMs * 2;
|
||||||
|
}
|
||||||
|
|
||||||
uint64_t AlexaBridgeLogic::hashBytes(const char *data, size_t length, uint64_t hash)
|
uint64_t AlexaBridgeLogic::hashBytes(const char *data, size_t length, uint64_t hash)
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < length; i++)
|
for (size_t i = 0; i < length; i++)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// The parts of the bridge that are plain logic: reassembling a directive from the fragments the MQTT client hands
|
// The parts of the bridge that are plain logic: reassembling a directive from the fragments the MQTT client hands
|
||||||
// over, queueing directives for loop(), telling a repeated directive from a new one, the limits on what is received
|
// over, queueing directives for loop(), telling a repeated directive from a new one, the limits on what is received
|
||||||
// and sent, topics and time stamps. Nothing here touches the MQTT client, Wi-Fi or Serial, so the same code runs in
|
// and sent, the wait between reconnects, topics and time stamps. Nothing here touches the MQTT client, Wi-Fi or Serial, so the same code runs in
|
||||||
// the host tests (test/test_bridge_logic, pio test -e native) with the byte sequences a broker would deliver.
|
// the host tests (test/test_bridge_logic, pio test -e native) with the byte sequences a broker would deliver.
|
||||||
#ifndef ALEXA_BRIDGE_LOGIC_H
|
#ifndef ALEXA_BRIDGE_LOGIC_H
|
||||||
#define ALEXA_BRIDGE_LOGIC_H
|
#define ALEXA_BRIDGE_LOGIC_H
|
||||||
|
|
@ -157,6 +157,32 @@ private:
|
||||||
AlexaRecentHashes recent;
|
AlexaRecentHashes recent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How long the bridge waits before it tries to open the MQTT session again: 1 s after a session ended, twice as
|
||||||
|
// long after every attempt that failed, a minute at most. A broker that is down or refuses the credentials is asked
|
||||||
|
// once a minute, and a session that dropped is back within seconds.
|
||||||
|
class AlexaReconnectBackoff
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static const uint32_t FIRST_WAIT_MS = 1000;
|
||||||
|
static const uint32_t LONGEST_WAIT_MS = 60000;
|
||||||
|
|
||||||
|
// The wait before the next attempt
|
||||||
|
uint32_t wait() const { return waitMs; }
|
||||||
|
|
||||||
|
// True when that wait is over. now and since are values of millis(), since the one of the moment the session
|
||||||
|
// ended or the attempt failed; the difference is right across the overflow of millis() after 49 days.
|
||||||
|
bool due(uint32_t now, uint32_t since) const { return now - since >= waitMs; }
|
||||||
|
|
||||||
|
// An attempt is made: if it fails, the one after it waits twice as long
|
||||||
|
void attempt();
|
||||||
|
|
||||||
|
// A session is open: the first attempt after it has ended waits FIRST_WAIT_MS again
|
||||||
|
void reset() { waitMs = FIRST_WAIT_MS; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint32_t waitMs = FIRST_WAIT_MS;
|
||||||
|
};
|
||||||
|
|
||||||
namespace AlexaBridgeLogic
|
namespace AlexaBridgeLogic
|
||||||
{
|
{
|
||||||
// FNV-1a, 64 bit. Two different texts have the same hash with a probability of 2^-64.
|
// FNV-1a, 64 bit. Two different texts have the same hash with a probability of 2^-64.
|
||||||
|
|
|
||||||
|
|
@ -753,6 +753,59 @@ void test_other_topics_are_not_directive_topics()
|
||||||
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaDirective", "root", nullptr));
|
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaDirective", "root", nullptr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- reconnect ---
|
||||||
|
|
||||||
|
void test_wait_doubles_from_a_second_to_a_minute()
|
||||||
|
{
|
||||||
|
AlexaReconnectBackoff backoff;
|
||||||
|
const uint32_t expected[] = {1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000, 60000};
|
||||||
|
|
||||||
|
for (uint32_t wait : expected)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(wait, backoff.wait());
|
||||||
|
backoff.attempt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_open_session_starts_the_wait_over()
|
||||||
|
{
|
||||||
|
AlexaReconnectBackoff backoff;
|
||||||
|
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
backoff.attempt();
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(60000, backoff.wait());
|
||||||
|
|
||||||
|
backoff.reset();
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(1000, backoff.wait());
|
||||||
|
backoff.attempt();
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(2000, backoff.wait());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_attempt_is_due_when_the_wait_is_over()
|
||||||
|
{
|
||||||
|
AlexaReconnectBackoff backoff;
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE(backoff.due(5000, 5000));
|
||||||
|
TEST_ASSERT_FALSE(backoff.due(5999, 5000));
|
||||||
|
TEST_ASSERT_TRUE(backoff.due(6000, 5000));
|
||||||
|
|
||||||
|
backoff.attempt();
|
||||||
|
TEST_ASSERT_FALSE(backoff.due(6999, 5000));
|
||||||
|
TEST_ASSERT_TRUE(backoff.due(7000, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_wait_is_counted_across_the_overflow_of_millis()
|
||||||
|
{
|
||||||
|
AlexaReconnectBackoff backoff;
|
||||||
|
const uint32_t since = 0xFFFFFF00u; // 256 ms before millis() starts again at 0
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE(backoff.due(0xFFFFFFFFu, since));
|
||||||
|
TEST_ASSERT_FALSE(backoff.due(743, since));
|
||||||
|
TEST_ASSERT_TRUE(backoff.due(744, since));
|
||||||
|
}
|
||||||
|
|
||||||
// --- time ---
|
// --- time ---
|
||||||
|
|
||||||
void test_timestamp_is_iso_8601_in_utc()
|
void test_timestamp_is_iso_8601_in_utc()
|
||||||
|
|
@ -848,6 +901,11 @@ int main(int, char **)
|
||||||
RUN_TEST(test_endpoint_is_taken_from_the_directive_topic);
|
RUN_TEST(test_endpoint_is_taken_from_the_directive_topic);
|
||||||
RUN_TEST(test_other_topics_are_not_directive_topics);
|
RUN_TEST(test_other_topics_are_not_directive_topics);
|
||||||
|
|
||||||
|
RUN_TEST(test_wait_doubles_from_a_second_to_a_minute);
|
||||||
|
RUN_TEST(test_open_session_starts_the_wait_over);
|
||||||
|
RUN_TEST(test_attempt_is_due_when_the_wait_is_over);
|
||||||
|
RUN_TEST(test_wait_is_counted_across_the_overflow_of_millis);
|
||||||
|
|
||||||
RUN_TEST(test_timestamp_is_iso_8601_in_utc);
|
RUN_TEST(test_timestamp_is_iso_8601_in_utc);
|
||||||
RUN_TEST(test_timestamp_needs_a_buffer_of_its_size);
|
RUN_TEST(test_timestamp_needs_a_buffer_of_its_size);
|
||||||
RUN_TEST(test_clock_counts_as_set_from_2024);
|
RUN_TEST(test_clock_counts_as_set_from_2024);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue