Reconnect: keep the wait across short sessions, name a timeout and a lost link, setServer()

The wait between attempts started at 1 s again when the broker accepted the
session, so a session that was closed right after it opened (two boards with
one client id) came back every 2 s without end. It now starts again only
after a session that lasted 60 s; across shorter ones it keeps doubling.
An attempt given up after 30 s prints "the broker did not answer within
30 s", and loss of Wi-Fi prints "Wi-Fi is down, waiting for it" once per loss.
setServer(host, port) before begin() names another broker; the host is not
copied. basicLight: static RAM 30,616 B (+16), flash 333,701 B (+264);
91 host tests (bridge logic 50).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 20:24:51 +00:00
parent d3e7ee7f2a
commit 42af413a6e
6 changed files with 164 additions and 23 deletions

View file

@ -66,12 +66,16 @@ namespace
}
Alex2ESP::Alex2ESP()
: rootTopic(),
: serverHost(MQTT_SERVER),
serverPort(MQTT_PORT),
rootTopic(),
state(Alex2ESPState::UNINITIALIZED),
disconnectReason(AsyncMqttClientDisconnectReason::TCP_DISCONNECTED),
useSntp(true),
clockWaitStarted(0),
linkWaitLogged(false),
linkWasUp(false),
attemptTimedOut(false),
clockWarned(false),
lastClockWarning(0),
waitingSince(0),
@ -121,7 +125,7 @@ void Alex2ESP::begin(const char *username, const char *password, const char *roo
{ this->onMessage(topic, payload, properties, len, index, total); });
// Configure MQTT client
mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
mqttClient.setServer(serverHost, serverPort);
mqttClient.setCredentials(username, password);
mqttClient.setKeepAlive(KEEP_ALIVE_S);
@ -130,6 +134,22 @@ void Alex2ESP::begin(const char *username, const char *password, const char *roo
state = Alex2ESPState::INITIALIZED;
}
void Alex2ESP::setServer(const char *host, uint16_t port)
{
if (state != Alex2ESPState::UNINITIALIZED)
{
ALEX2ESP_LOGE("setServer() ignored: call it before begin()");
return;
}
if (host == nullptr || host[0] == '\0' || port == 0)
{
ALEX2ESP_LOGE("setServer() ignored: it needs the name or the address of the broker and its port");
return;
}
serverHost = host;
serverPort = port;
}
void Alex2ESP::setLogLevel(AlexaLogLevel level)
{
AlexaLog::setLevel(level);
@ -174,6 +194,22 @@ AlexaDevice *Alex2ESP::getDevice(const String &name, const String &endpointId)
return device;
}
// The MQTT client learns of a lost link when its keep-alive runs out, and reports a lost connection then. The
// line names the cause at the moment the link goes down, once per loss.
void Alex2ESP::watchLink()
{
if (state == Alex2ESPState::UNINITIALIZED)
{
return;
}
const bool linkIsUp = WiFi.status() == WL_CONNECTED;
if (linkWasUp && !linkIsUp)
{
ALEX2ESP_LOGE("Wi-Fi is down, waiting for it");
}
linkWasUp = linkIsUp;
}
// The first connect waits for Wi-Fi and then until the clock is set, for CLOCK_WAIT_MS at most: a report sent before
// SNTP has answered would carry a time of sample in 1970.
void Alex2ESP::connectWhenClockIsSet()
@ -220,11 +256,13 @@ void Alex2ESP::handleMqttReconnection()
{
// 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().
attemptTimedOut = true;
mqttClient.disconnect(true);
if (state == Alex2ESPState::CONNECTING)
{
onMqttDisconnect(AsyncMqttClientDisconnectReason::TCP_DISCONNECTED);
}
attemptTimedOut = false;
}
if (state != Alex2ESPState::DISCONNECTED || !backoff.due(millis(), waitingSince))
@ -255,8 +293,8 @@ void Alex2ESP::onMqttConnect(bool sessionPresent)
mqttClient.disconnect();
return;
}
backoff.reset();
ALEX2ESP_LOGI("connected to %s, subscribing", MQTT_SERVER);
backoff.sessionOpened(millis());
ALEX2ESP_LOGI("connected to %s, subscribing", serverHost);
}
void Alex2ESP::onSubscribe(uint16_t packetId, uint8_t qos)
@ -286,11 +324,20 @@ void Alex2ESP::onMqttDisconnect(AsyncMqttClientDisconnectReason reason)
state = Alex2ESPState::DISCONNECTED;
disconnectReason = reason;
waitingSince = millis();
backoff.sessionEnded(waitingSince);
directives.cancelArrival();
char words[96];
strncpy_P(words, disconnectReasonText(reason), sizeof(words) - 1);
words[sizeof(words) - 1] = '\0';
if (attemptTimedOut)
{
// The client knows no reason of its own for an attempt that the bridge gave up
snprintf(words, sizeof(words), PSTR("the broker did not answer within %u s"), (unsigned)(CONNECT_TIMEOUT_MS / 1000));
}
else
{
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));
@ -601,6 +648,7 @@ void Alex2ESP::logRefusal(const char *topic, AlexaSendResult result, size_t leng
void Alex2ESP::loop()
{
watchLink();
connectWhenClockIsSet();
handleMqttReconnection();
continueDiscovery();

View file

@ -5,7 +5,7 @@
* @contributors chaos511
*
* @description Companion library of the Alex2MQTT Alexa skill: the devices a sketch declares become Alexa
* endpoints through the MQTT broker at alex2mqtt.stormysdream.club.
* endpoints through the MQTT broker at alex2mqtt.stormysdream.club, or the one of setServer().
*
* <root>/discover in answered with one discovery object per device on <root>/discover_r
* <root>/<endpointId>/alexaDirective in the directive, handed to the handler of the device (onDirective(), or
@ -47,8 +47,14 @@ public:
// 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 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().
// a minute, while Wi-Fi is up; the reason is printed and returned by getDisconnectReason(). The wait is 1 s
// again after a session that lasted a minute.
void begin(const char *username, const char *password, const char *rootTopic);
// Before begin(), for a broker other than alex2mqtt.stormysdream.club:1883: a name or an address as text.
// The host is not copied: it has to stay valid for as long as the client is used. After begin() the call is
// ignored with an error.
void setServer(const char *host, uint16_t port);
Alex2ESPState getState() const;
// Call from the sketch's loop(): connects, answers discovery requests and hands one directive per call to its
@ -81,6 +87,8 @@ private:
static const unsigned long DISCOVERY_RETRY_MS = 20; // Pause before a refused discovery publish is tried again
AsyncMqttClient mqttClient; // MQTT client instance
const char *serverHost; // The broker: the default, or what setServer() was given
uint16_t serverPort;
String rootTopic; // Root topic for communication
String discoverTopic; // The topic we listen on for discovery messages
String discoverTopicSend; // The topic we send discovery messages
@ -93,6 +101,8 @@ private:
bool useSntp;
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 linkWasUp; // Wi-Fi was up when loop() looked last
bool attemptTimedOut; // The connect that is being closed had no answer within CONNECT_TIMEOUT_MS
bool clockWarned; // The unset clock has been reported
unsigned long lastClockWarning; // millis() of that report
unsigned long waitingSince; // millis() of the connect while CONNECTING, of the disconnect while DISCONNECTED
@ -119,6 +129,7 @@ private:
void onMessage(char *topic, char *payload, AsyncMqttClientMessageProperties properties, size_t length, size_t index, size_t total);
//loop processing function
void watchLink();
void connectWhenClockIsSet();
void connect();
void warnAboutClock();

View file

@ -199,6 +199,21 @@ void AlexaReconnectBackoff::attempt()
waitMs = (waitMs >= LONGEST_WAIT_MS / 2) ? LONGEST_WAIT_MS : waitMs * 2;
}
void AlexaReconnectBackoff::sessionOpened(uint32_t now)
{
openedAt = now;
open = true;
}
void AlexaReconnectBackoff::sessionEnded(uint32_t now)
{
if (open && now - openedAt >= STABLE_SESSION_MS)
{
waitMs = FIRST_WAIT_MS;
}
open = false;
}
uint64_t AlexaBridgeLogic::hashBytes(const char *data, size_t length, uint64_t hash)
{
for (size_t i = 0; i < length; i++)

View file

@ -157,14 +157,19 @@ private:
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.
// How long the bridge waits before it tries to open the MQTT session again: 1 s at first, twice as long after
// every attempt, 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.
//
// The wait starts at 1 s again when a session has lasted a minute, not when it opens. A broker accepts a session
// and closes it at once when a second board uses the same client id, and the two would take the session from each
// other every second without end; so the waits keep doubling across sessions that end early.
class AlexaReconnectBackoff
{
public:
static const uint32_t FIRST_WAIT_MS = 1000;
static const uint32_t LONGEST_WAIT_MS = 60000;
static const uint32_t STABLE_SESSION_MS = 60000;
// The wait before the next attempt
uint32_t wait() const { return waitMs; }
@ -173,14 +178,21 @@ public:
// 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
// An attempt is made: 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; }
// The broker has accepted the session; now is the value of millis()
void sessionOpened(uint32_t now);
// The session has ended, or the attempt has failed. After a session of STABLE_SESSION_MS or more the next
// attempt waits FIRST_WAIT_MS; after a shorter one, and after an attempt that opened none, the wait stays.
// A session that ends within STABLE_SESSION_MS after millis() has overflowed (49 days) counts as a short one.
void sessionEnded(uint32_t now);
private:
uint32_t waitMs = FIRST_WAIT_MS;
uint32_t openedAt = 0; // millis() when the session was opened, while one is open
bool open = false;
};
namespace AlexaBridgeLogic