diff --git a/readme.md b/readme.md index b2663da..20c0325 100644 --- a/readme.md +++ b/readme.md @@ -194,9 +194,9 @@ for (AlexaDevice* lamp : lamps) { ## How it talks to Alex2MQTT -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. `alexClient.setServer("broker.example.org", 1883)` before `begin()` names another broker, for example your own instance of Alex2MQTT. The host is a name or an address as text and is not copied, like the username and the password: pass a literal or a buffer that stays. -- **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 `/discover` and `/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to /discover` means that the root topic is not the one of the account. A session that ends or cannot be opened prints `[Alex2ESP] error: disconnected: ; 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. +- **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 `/discover` and `/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to /discover` means that the root topic is not the one of the account. A session that ends or cannot be opened prints `[Alex2ESP] error: disconnected: ; 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 after a session that lasted a minute: a session that the broker closes right after it has accepted it, as it does to one of two boards with the same client id, is opened again after the longer wait. `the broker refused the username or the password` is the reason to look for when a board never shows up in Alexa, `the broker did not answer within 30 s` stands for an attempt that the library gave up. When the link goes down the library prints `[Alex2ESP] error: Wi-Fi is down, waiting for it`, once per loss; the MQTT client reports the lost connection when its keep-alive runs out, and the session is opened again when Wi-Fi is back. 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 `/discover` the library answers with one discovery object per device on `/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 ` 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 `//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 calls the handler of the device: the one of `onDirective()`, or else `ReportState` or `Event` of `registerEvent()` (and before it `DirectiveReceived`, if registered) with the directive: `directive["header"]`, `directive["endpoint"]`, `directive["payload"]`. A device without a handler answers with the ErrorResponse `INVALID_DIRECTIVE`, and so does a device whose handler sent nothing for a directive that names a capability the device does not have; both print an error. A handler that sends nothing for a capability of its device leaves the directive unanswered, which prints `[Alex2ESP] error: : the handler sent no answer to ...`. 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 `//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`. @@ -282,10 +282,10 @@ 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. - `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 (without its root) 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. -- 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). +- 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 at first and doubles with every attempt, up to 60 s; it is 1 s again after a session that lasted a minute, so a session that the broker accepts and closes at once does not bring the board back every second. 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). Loss of Wi-Fi prints `Wi-Fi is down, waiting for it`, once per loss. A connect that has no answer after 30 s counts as failed and prints `disconnected: the broker did not answer within 30 s`. 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. - 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 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::setServer(host, port)` before `begin()`, for a broker other than the default; the host is not copied. `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`. - Interfaces are described by rows in program memory, `AlexaInterfaces::PowerController` and 27 more (see Interface Types), in place of the `switch` tables of `AlexaInterfaceUtils`, whose strings took RAM in every sketch. `addCapability()` takes a row or, as before, an `AlexaInterfaceType`; a sketch links only the rows it names. The discovery objects of the five examples are the same byte for byte. - Versions and properties follow the interface pages of Alexa: `EndpointHealth` is announced as 3.1 (1.1.0: 3.3) and `ThermostatController` as 3.2 with its four properties (1.1.0: 3, none). `Speaker`, `StepSpeaker`, `PlaybackStateReporter`, `InventoryLevelSensor` and `WakeOnLANController` are announced as 3 (1.1.0: 1), `SimpleEventSource` as 1.0. The interfaces that 1.1.0 announced with an empty list of properties have their properties (`lockState`, `detectionState`, `mode`, `rangeValue`, `percentage`, ...). `SceneController`, `DoorbellEventSource`, `StepSpeaker` and `SimpleEventSource` are announced without a `properties` object. @@ -302,9 +302,9 @@ Behaviour changes: - `PlaybackController` and `WakeOnLANController` are announced with `"properties": {}`, as their pages show them. - Removed: `AlexaInterface::getJSON()` (`toJson()` adds the capability to the capabilities of its endpoint) and `getProps()` (the row has the properties), `ActionMapping::getJSON()` and the `String` and `std::vector` members of `ActionMapping`, the class `FriendlyName`. -Memory: `examples/basicLight.cpp` for a D1 mini takes 30,600 bytes of static RAM (1.1.0: 52,768) and 333,437 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). 18,260 bytes of the static RAM were the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients; 3,720 were the names, versions and properties of all interfaces and the names of the display categories, which are in flash now. SNTP and the time stamp are 1.8 KB of the flash figure. The instance, names, configuration and mappings of a capability are 2.0 KB of it, the directive handler and the ErrorResponse 0.9 KB. +Memory: `examples/basicLight.cpp` for a D1 mini takes 30,616 bytes of static RAM (1.1.0: 52,768) and 333,701 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). 18,260 bytes of the static RAM were the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients; 3,720 were the names, versions and properties of all interfaces and the names of the display categories, which are in flash now. SNTP and the time stamp are 1.8 KB of the flash figure. The instance, names, configuration and mappings of a capability are 2.0 KB of it, the directive handler and the ErrorResponse 0.9 KB. -Tests: `pio test -e native` in the repository runs 88 host tests: 47 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), 30 of discovery (the discovery object of every example against what 1.1.0 announced, the 28 rows against the interface pages, a type without a row, the discovery objects of a range, a mode and a toggle controller, a scene and a doorbell, what a capability refuses) and 11 of dispatch (the device and the capability a directive reaches, one handler for two devices, the handlers of 1.x, a capability more than a device holds, the answers to a directive for a capability the device lacks and to one without a handler). No board is needed. +Tests: `pio test -e native` in the repository runs 91 host tests: 50 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), 30 of discovery (the discovery object of every example against what 1.1.0 announced, the 28 rows against the interface pages, a type without a row, the discovery objects of a range, a mode and a toggle controller, a scene and a doorbell, what a capability refuses) and 11 of dispatch (the device and the capability a directive reaches, one handler for two devices, the handlers of 1.x, a capability more than a device holds, the answers to a directive for a capability the device lacks and to one without a handler). 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. diff --git a/src/Alex2ESP.cpp b/src/Alex2ESP.cpp index 49808eb..1954497 100644 --- a/src/Alex2ESP.cpp +++ b/src/Alex2ESP.cpp @@ -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(); diff --git a/src/Alex2ESP.h b/src/Alex2ESP.h index d866b4e..9ae27a5 100644 --- a/src/Alex2ESP.h +++ b/src/Alex2ESP.h @@ -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(). * * /discover in answered with one discovery object per device on /discover_r * //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(); diff --git a/src/AlexaBridgeLogic.cpp b/src/AlexaBridgeLogic.cpp index 9a3b4b1..220b926 100644 --- a/src/AlexaBridgeLogic.cpp +++ b/src/AlexaBridgeLogic.cpp @@ -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++) diff --git a/src/AlexaBridgeLogic.h b/src/AlexaBridgeLogic.h index d9c177d..ffd7d63 100644 --- a/src/AlexaBridgeLogic.h +++ b/src/AlexaBridgeLogic.h @@ -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 diff --git a/test/test_bridge_logic/test_main.cpp b/test/test_bridge_logic/test_main.cpp index d06618f..3aa809d 100644 --- a/test/test_bridge_logic/test_main.cpp +++ b/test/test_bridge_logic/test_main.cpp @@ -791,7 +791,7 @@ void test_wait_doubles_from_a_second_to_a_minute() } } -void test_open_session_starts_the_wait_over() +void test_session_that_lasted_a_minute_starts_the_wait_over() { AlexaReconnectBackoff backoff; @@ -801,12 +801,64 @@ void test_open_session_starts_the_wait_over() } TEST_ASSERT_EQUAL_UINT32(60000, backoff.wait()); - backoff.reset(); + backoff.sessionOpened(100000); + TEST_ASSERT_EQUAL_UINT32(60000, backoff.wait()); + backoff.sessionEnded(160000); TEST_ASSERT_EQUAL_UINT32(1000, backoff.wait()); backoff.attempt(); TEST_ASSERT_EQUAL_UINT32(2000, backoff.wait()); } +// A broker that accepts the session and closes it at once, as it does to one of two boards with the same client id +void test_wait_keeps_doubling_across_sessions_that_end_early() +{ + AlexaReconnectBackoff backoff; + const uint32_t expected[] = {1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000}; + uint32_t now = 5000; + + for (uint32_t wait : expected) + { + backoff.sessionEnded(now); + TEST_ASSERT_EQUAL_UINT32(wait, backoff.wait()); + now += wait; + backoff.attempt(); + now += 200; // until the broker has accepted the session + backoff.sessionOpened(now); + now += 59999; + } +} + +void test_attempt_that_fails_after_a_long_session_does_not_start_the_wait_over() +{ + AlexaReconnectBackoff backoff; + + backoff.sessionOpened(1000); + backoff.sessionEnded(3600000); + TEST_ASSERT_EQUAL_UINT32(1000, backoff.wait()); + + // The broker is gone: every attempt ends without a session, however long it took + backoff.attempt(); + backoff.sessionEnded(3700000); + TEST_ASSERT_EQUAL_UINT32(2000, backoff.wait()); + backoff.attempt(); + backoff.sessionEnded(3800000); + TEST_ASSERT_EQUAL_UINT32(4000, backoff.wait()); +} + +void test_length_of_a_session_is_counted_across_the_overflow_of_millis() +{ + AlexaReconnectBackoff backoff; + + backoff.attempt(); + backoff.sessionOpened(0xFFFFFF00u); // 256 ms before millis() starts again at 0 + backoff.sessionEnded(59743); + TEST_ASSERT_EQUAL_UINT32(2000, backoff.wait()); + + backoff.sessionOpened(0xFFFFFF00u); + backoff.sessionEnded(59744); + TEST_ASSERT_EQUAL_UINT32(1000, backoff.wait()); +} + void test_attempt_is_due_when_the_wait_is_over() { AlexaReconnectBackoff backoff; @@ -928,7 +980,10 @@ int main(int, char **) RUN_TEST(test_log_never_prints_the_root_topic); RUN_TEST(test_wait_doubles_from_a_second_to_a_minute); - RUN_TEST(test_open_session_starts_the_wait_over); + RUN_TEST(test_session_that_lasted_a_minute_starts_the_wait_over); + RUN_TEST(test_wait_keeps_doubling_across_sessions_that_end_early); + RUN_TEST(test_attempt_that_fails_after_a_long_session_does_not_start_the_wait_over); + RUN_TEST(test_length_of_a_session_is_counted_across_the_overflow_of_millis); RUN_TEST(test_attempt_is_due_when_the_wait_is_over); RUN_TEST(test_wait_is_counted_across_the_overflow_of_millis);