From 113dc06805b2bf9f0ed058b55950a14326d3e04a Mon Sep 17 00:00:00 2001 From: Willi Thiel Date: Tue, 20 Oct 2026 13:20:11 +0200 Subject: [PATCH] Back off from failed connections and test the firmware natively When WiFi was down, v1.1 slept a fixed 6 hours after one failed attempt. That is longer than the ESP8266 can sleep at once (ESP.deepSleepMax(), about 3.5 hours), a single router reboot cost 6 hours of data, and a broker that does not answer was not covered at all: the device stayed awake 25 seconds every 5 minutes. Failed attempts are now counted in RTC memory, and the wait doubles from 10 minutes to at most 2 hours (BACKOFF_MAX_SLEEPTIME). It is made of single 5-minute deep sleeps; a wake that only counts down keeps the radio and the sensor off. A broker timeout counts like a WiFi failure, a connection the access point refuses outright is given up at once, the first transmission reports the failures before it as connectFailures, and OTA mode never waits. The battery percentage is clamped to 0..100 and computed in integers, so a full battery reads 100 instead of 99. src/main.cpp now also runs on the build machine against fakes of the Arduino core and its libraries (test/support/fakes); 23 Unity tests drive the real setup() and loop() and cover 99 % of the lines. CI runs them with a coverage floor and builds the firmware. --- .github/workflows/ci.yml | 64 +++++ README.md | 17 ++ include/config.h.example | 1 + include/rtc_data.h | 19 ++ lib/backoff/backoff.h | 34 +++ platformio.ini | 19 +- src/main.cpp | 143 +++++++--- test/support/fakes/Adafruit_HTU21DF.h | 11 + test/support/fakes/Arduino.h | 52 ++++ test/support/fakes/ArduinoOTA.h | 31 ++ test/support/fakes/ESP8266WiFi.h | 51 ++++ test/support/fakes/IPAddress.h | 13 + test/support/fakes/PubSubClient.h | 15 + test/support/fakes/config.h | 25 ++ test/support/fakes/fake.h | 88 ++++++ test/support/fakes/fakes.cpp | 216 ++++++++++++++ test/test_esptemp/harness.cpp | 62 ++++ test/test_esptemp/harness.h | 30 ++ test/test_esptemp/runner.cpp | 12 + test/test_esptemp/test_backoff.cpp | 44 +++ test/test_esptemp/test_firmware.cpp | 396 ++++++++++++++++++++++++++ 21 files changed, 1295 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 include/rtc_data.h create mode 100644 lib/backoff/backoff.h create mode 100644 test/support/fakes/Adafruit_HTU21DF.h create mode 100644 test/support/fakes/Arduino.h create mode 100644 test/support/fakes/ArduinoOTA.h create mode 100644 test/support/fakes/ESP8266WiFi.h create mode 100644 test/support/fakes/IPAddress.h create mode 100644 test/support/fakes/PubSubClient.h create mode 100644 test/support/fakes/config.h create mode 100644 test/support/fakes/fake.h create mode 100644 test/support/fakes/fakes.cpp create mode 100644 test/test_esptemp/harness.cpp create mode 100644 test/test_esptemp/harness.h create mode 100644 test/test_esptemp/runner.cpp create mode 100644 test/test_esptemp/test_backoff.cpp create mode 100644 test/test_esptemp/test_firmware.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..657505f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + PLATFORMIO_VERSION: "6.1.19" + GCOVR_VERSION: "8.6" + +jobs: + test: + name: test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.platformio + key: platformio-native-${{ hashFiles('platformio.ini') }} + - name: Install PlatformIO and gcovr + run: pip install "platformio==${PLATFORMIO_VERSION}" "gcovr==${GCOVR_VERSION}" + - name: Unit tests + run: pio test -e native + - name: Coverage + run: >- + gcovr --root . --filter src/ --filter lib/ --exclude-throw-branches + --print-summary --txt - + --fail-under-line 90 --fail-under-branch 80 + + firmware: + name: firmware + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.platformio + key: platformio-nodemcuv2-${{ hashFiles('platformio.ini') }} + - name: Install PlatformIO + run: pip install "platformio==${PLATFORMIO_VERSION}" + - name: Example configuration + run: cp include/config.h.example include/config.h + - name: Build + run: pio run -e nodemcuv2 diff --git a/README.md b/README.md index bb3f923..bf3715d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,23 @@ The firmware is written in C++ using [Visual Studio Code](https://code.visualstu > ⚠️ Before building the firmware be sure to copy the file `include\config.h.example` to `include/config.h` and change the WiFi and MQTT credentials as well as the other settings in the new file. +### Without WiFi or MQTT + +When the device cannot connect to the WiFi, or the MQTT broker does not answer, it counts the failed attempt in RTC memory and waits longer before the next one: 10, 20, 40 and 80 minutes, then every 2 hours (`BACKOFF_MAX_SLEEPTIME`). The first successful transmission returns to the normal 5 minutes and reports the number of failed attempts before it as `connectFailures`. A connection the access point refuses outright (unknown SSID, wrong password) is given up at once instead of after the 20-second timeout. + +The wait is made of single 5-minute deep sleeps: a wake that only counts down keeps the radio off and goes back to sleep right away. A single long deep sleep is not used, because the ESP8266 cannot sleep longer than `ESP.deepSleepMax()`, about 3.5 hours. + +### Tests + +The firmware runs on the build machine against small fakes of the Arduino core and the libraries (`test/support/fakes`), so the tests drive the real `setup()` and `loop()`: + +```sh +pio test -e native +gcovr --root . --filter src/ --filter lib/ --exclude-throw-branches --print-summary +``` + +CI requires 90 % line and 80 % branch coverage. + ### Flash via serial connection Connect RX, TX and GND to your serial interface or [USB-to-TTL converter](https://aliexpress.com/item/32531899568.html). To [enter flash mode on the ESP-12F](https://lukelogbook.tech/2018/02/08/flash-code-to-esp8266-esp-12-module-step-by-step-guide/) you must connect **GPIO0** to **GND** and reset the MCU. diff --git a/include/config.h.example b/include/config.h.example index 35db1a9..ee7b887 100644 --- a/include/config.h.example +++ b/include/config.h.example @@ -26,5 +26,6 @@ IPAddress ip(192, 168, 0, 2); // #define OTA_ENABLE_GPIO 15 /**< Override OTA enable GPIO (Default: 14) */ // #define SLEEPTIME 600e6 /**< Time in deepsleep (Default: 300e6 = 300 seconds)*/ +// #define BACKOFF_MAX_SLEEPTIME 3600e6 /**< Longest wait between attempts while WiFi or MQTT fails (Default: 7200e6 = 2 hours)*/ // #define DEBUG 1 /**< Set to 1 to debug over serial (Default: 0)*/ // #define OTA 0 /**< Set to 1 to enable OTA waiting loop (Default: 1)*/ diff --git a/include/rtc_data.h b/include/rtc_data.h new file mode 100644 index 0000000..142874b --- /dev/null +++ b/include/rtc_data.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +/** + * Kept in RTC memory across deep sleep. The CRC covers everything after it. + */ +struct RtcData +{ + uint32_t crc32; // 4 bytes + uint8_t channel; // 1 byte, 5 in total + uint8_t ap_mac[6]; // 6 bytes, 11 in total + uint8_t apValid; // 1 byte, 12 in total: channel and ap_mac are known + uint8_t failures; // 1 byte, 13 in total: failed connection attempts in a row + uint8_t reserved; // 1 byte, 14 in total + uint16_t sleepsLeft; // 2 bytes, 16 in total: sleep periods to skip before the next attempt +}; + +static_assert(sizeof(RtcData) == 16, "RtcData must stay a multiple of 4 bytes"); diff --git a/lib/backoff/backoff.h b/lib/backoff/backoff.h new file mode 100644 index 0000000..21d859e --- /dev/null +++ b/lib/backoff/backoff.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +/** + * Number of whole sleep periods that fit into the longest wait, at least one. + */ +inline uint16_t backoffCap(double maxSleepUs, double sleepUs) +{ + if (sleepUs <= 0 || maxSleepUs < sleepUs) + { + return 1; + } + double periods = maxSleepUs / sleepUs; + return periods > 65535 ? 65535 : (uint16_t)periods; +} + +/** + * Sleep periods to wait before the next connection attempt after `failures` + * failed attempts in a row: 1, 2, 4, 8, ... up to `cap`. + */ +inline uint16_t backoffSleeps(uint8_t failures, uint16_t cap) +{ + if (cap < 1) + { + cap = 1; + } + if (failures >= 16) + { + return cap; + } + uint32_t periods = 1UL << failures; + return periods < cap ? (uint16_t)periods : cap; +} diff --git a/platformio.ini b/platformio.ini index f76721b..354c17f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,13 +8,26 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html +[platformio] +default_envs = nodemcuv2 + [env:nodemcuv2] -platform = espressif8266 +platform = espressif8266@4.2.1 board = nodemcuv2 framework = arduino -lib_deps = +lib_deps = knolleary/PubSubClient@^2.8 bblanchon/ArduinoJson@^6.21.2 adafruit/Adafruit HTU21DF Library@^1.0.5 board_build.f_cpu = 160000000L -monitor_speed = 115200 \ No newline at end of file +monitor_speed = 115200 + +; src/main.cpp on the build machine, against the fakes in test/support/fakes +[env:native] +platform = native +test_framework = unity +test_build_src = yes +lib_extra_dirs = test/support +lib_deps = + bblanchon/ArduinoJson@^6.21.2 +build_flags = -O0 -g --coverage -lgcov diff --git a/src/main.cpp b/src/main.cpp index 7082198..944197c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include "config.h" +#include "rtc_data.h" #ifndef ESPTEMP_VERSION -#define ESPTEMP_VERSION "v1.1" /**< The current version of the ESPtemp firmware*/ +#define ESPTEMP_VERSION "v1.2" /**< The current version of the ESPtemp firmware*/ #endif #ifndef OTA_ENABLE_GPIO @@ -23,8 +25,8 @@ #define SLEEPTIME 300e6 /**< Time in deepsleep */ #endif -#ifndef SLEEPTIME_EXTENDED -#define SLEEPTIME_EXTENDED 21600e6 /**< Time in deepsleep if an error occured while trying to connect to wifi (to avoid battery drainage) */ +#ifndef BACKOFF_MAX_SLEEPTIME +#define BACKOFF_MAX_SLEEPTIME 7200e6 /**< Longest wait between two attempts while connecting to WiFi or MQTT keeps failing */ #endif #ifndef DEBUG @@ -58,9 +60,7 @@ float temperature; float batteryVoltage; int8_t batteryPercentage; uint_fast16_t adcValue; -char error[15]; -uint8_t wifiRetries = 0; uint_fast16_t i = 0; bool isHTU21DFready = false; @@ -80,12 +80,7 @@ Adafruit_HTU21DF htu = Adafruit_HTU21DF(); WiFiClient wifiClient; PubSubClient pubSubclient(wifiClient); -struct -{ - uint32_t crc32; // 4 bytes - uint8_t channel; // 1 byte, 5 in total - uint8_t ap_mac[6]; // 6 bytes, 11 in total -} rtcData; +RtcData rtcData; // the CRC routine uint32_t calculateCRC32(const uint8_t *data, size_t length) @@ -118,7 +113,9 @@ float readBatteryVoltage(void) { adcValue = analogRead(A0); batteryVoltage = adcValue * 0.004; - batteryPercentage = (int8_t)((adcValue - 863) / 1.6); + // ADC 863 (3.45 V) is empty, 1023 (4.1 V) is full; integer maths, so full is exactly 100 + int_fast32_t percentage = ((int_fast32_t)adcValue - 863) * 10 / 16; + batteryPercentage = (int8_t)(percentage < 0 ? 0 : (percentage > 100 ? 100 : percentage)); if (batteryVoltage <= 3.3) { @@ -135,8 +132,55 @@ float readBatteryVoltage(void) return batteryVoltage; } +// Read the RTC data; anything that fails its CRC counts as a first boot +void readRtc(void) +{ + bool isRtcReadable = ESP.rtcUserMemoryRead(0, (uint32_t *)&rtcData, sizeof(rtcData)); + // Skip the first 4 bytes, that's the checksum itself + if (!isRtcReadable || calculateCRC32(((uint8_t *)&rtcData) + 4, sizeof(rtcData) - 4) != rtcData.crc32) + { + memset(&rtcData, 0, sizeof(rtcData)); + } + isRtcValid = rtcData.apValid; +} + +void writeRtc(void) +{ + rtcData.crc32 = calculateCRC32(((uint8_t *)&rtcData) + 4, sizeof(rtcData) - 4); + ESP.rtcUserMemoryWrite(0, (uint32_t *)&rtcData, sizeof(rtcData)); +} + +// Count the failed attempt and wait longer before the next one (see backoff.h) +void sleepAfterFailure(const char *reason) +{ + if (rtcData.failures < 255) + { + rtcData.failures++; + } + rtcData.sleepsLeft = backoffSleeps(rtcData.failures, backoffCap(BACKOFF_MAX_SLEEPTIME, SLEEPTIME)) - 1; + writeRtc(); + + WiFi.disconnect(true); + D_timestamp(); + D_printf("%s (%u failed attempts), next attempt in %u periods\n", reason, rtcData.failures, rtcData.sleepsLeft + 1); + ESP.deepSleep(SLEEPTIME, WAKE_RF_DISABLED); +} + void setup() { + // Every wake from deep sleep is a fresh boot + isHTU21DFready = false; + isBatteryVoltageReady = false; + isWifiStarted = false; + isWifiReady = false; + isRegularWifi = false; + isMQTTStarted = false; + isMQTTReady = false; + isRtcValid = false; + isOTAEnabled = false; + ledState = HIGH; + i = 0; + D_SerialBegin(115200); // internal LED off @@ -154,6 +198,24 @@ void setup() D_print("MAC Address: "); D_println(WiFi.macAddress()); + readRtc(); + +#if OTA + pinMode(OTA_ENABLE_GPIO, INPUT); + isOTAEnabled = (digitalRead(OTA_ENABLE_GPIO) == HIGH); +#endif + + // Sit out the wait after failed attempts one sleep period at a time: a single long + // deep sleep can exceed ESP.deepSleepMax(). OTA mode never waits. + if (rtcData.sleepsLeft > 0 && !isOTAEnabled) + { + rtcData.sleepsLeft--; + writeRtc(); + D_timestamp(); + D_printf("Waiting after failed attempts, %u periods left\n", rtcData.sleepsLeft); + ESP.deepSleep(SLEEPTIME, WAKE_RF_DISABLED); + } + // enable HTU21DF_VCC pinMode(HTU21DF_VCC_GPIO, OUTPUT); digitalWrite(HTU21DF_VCC_GPIO, HIGH); @@ -162,16 +224,6 @@ void setup() // Wait minimum sampling period startMillis = millis(); - if (ESP.rtcUserMemoryRead(0, (uint32_t *)&rtcData, sizeof(rtcData))) - { - // Calculate the CRC of what we just read from RTC memory, but skip the first 4 bytes as that's the checksum itself. - uint32_t crc = calculateCRC32(((uint8_t *)&rtcData) + 4, sizeof(rtcData) - 4); - if (crc == rtcData.crc32) - { - isRtcValid = true; - } - } - #if OTA #if DEBUG ArduinoOTA.onStart([]() @@ -205,8 +257,6 @@ void setup() else if (error == OTA_END_ERROR) D_println("End Failed"); }); #endif - pinMode(OTA_ENABLE_GPIO, INPUT); - isOTAEnabled = (digitalRead(OTA_ENABLE_GPIO) == HIGH); if (isOTAEnabled) { D_timestamp(); @@ -305,7 +355,8 @@ void loop() */ if (!isWifiReady) { - if (WiFi.status() == WL_CONNECTED) + wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus == WL_CONNECTED) { D_timestamp(); D_print("WiFi connected ("); @@ -322,6 +373,11 @@ void loop() ArduinoOTA.begin(); } } + else if (isRegularWifi && (wifiStatus == WL_NO_SSID_AVAIL || wifiStatus == WL_CONNECT_FAILED || wifiStatus == WL_WRONG_PASSWORD)) + { + // No point in waiting for the timeout + sleepAfterFailure("WiFi connection refused"); + } else if (((millis() - startMillis) > 5000) && (!isRegularWifi)) { // Try regular connection @@ -339,11 +395,7 @@ void loop() } else if ((millis() - startMillis) > 20000) { - // Wifi connection failed, go to extended sleep - WiFi.disconnect(true); - D_timestamp(); - D_println("Connection to WiFi failed, going to extended sleep"); - ESP.deepSleep(SLEEPTIME_EXTENDED, WAKE_RF_DISABLED); + sleepAfterFailure("Connection to WiFi failed"); } } /* @@ -379,8 +431,9 @@ void loop() doc["batteryVoltage"] = (int)roundf(100 * batteryVoltage) / 100.0; doc["batteryPercentage"] = batteryPercentage; doc["firmwareVersion"] = ESPTEMP_VERSION; + doc["connectFailures"] = rtcData.failures; - char json[128]; + char json[192]; serializeJson(doc, json); D_timestamp(); @@ -394,25 +447,27 @@ void loop() delay(10); isMQTTReady = true; + + // Connected: forget the failures and save the access point for a quick connection + if (!isRtcValid) + { + rtcData.channel = WiFi.channel(); + memcpy(rtcData.ap_mac, WiFi.BSSID(), 6); // Copy 6 bytes of BSSID (AP's MAC address) + rtcData.apValid = 1; + } + rtcData.failures = 0; + rtcData.sleepsLeft = 0; + writeRtc(); } if (!isOTAEnabled) { /* * Step 6: - * Save WiFi data to RTC and go to deep sleep again + * Go to deep sleep again */ if (isMQTTReady) { - // Write current connection info to RTC - if (!isRtcValid) - { - rtcData.channel = WiFi.channel(); - memcpy(rtcData.ap_mac, WiFi.BSSID(), 6); // Copy 6 bytes of BSSID (AP's MAC address) - rtcData.crc32 = calculateCRC32(((uint8_t *)&rtcData) + 4, sizeof(rtcData) - 4); - ESP.rtcUserMemoryWrite(0, (uint32_t *)&rtcData, sizeof(rtcData)); - } - WiFi.disconnect(true); D_timestamp(); @@ -421,10 +476,8 @@ void loop() } else if ((millis() - startMillis) > 25000) { - WiFi.disconnect(true); - D_timestamp(); - D_println("TIMEOUT, going to sleep"); - ESP.deepSleep(SLEEPTIME, WAKE_RF_DISABLED); + // WiFi is up, but the MQTT broker does not answer + sleepAfterFailure("TIMEOUT"); } } /* diff --git a/test/support/fakes/Adafruit_HTU21DF.h b/test/support/fakes/Adafruit_HTU21DF.h new file mode 100644 index 0000000..a1a63c5 --- /dev/null +++ b/test/support/fakes/Adafruit_HTU21DF.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class Adafruit_HTU21DF +{ +public: + bool begin(); + float readTemperature(); + float readHumidity(); +}; diff --git a/test/support/fakes/Arduino.h b/test/support/fakes/Arduino.h new file mode 100644 index 0000000..da2ca15 --- /dev/null +++ b/test/support/fakes/Arduino.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "fake.h" + +#define HIGH 0x1 +#define LOW 0x0 +#define INPUT 0x00 +#define OUTPUT 0x01 +#define LED_BUILTIN 2 +#define A0 17 + +unsigned long millis(); +void delay(unsigned long ms); +void pinMode(uint8_t pin, uint8_t mode); +void digitalWrite(uint8_t pin, uint8_t value); +int digitalRead(uint8_t pin); +int analogRead(uint8_t pin); + +class String : public std::string +{ +public: + using std::string::string; + String() = default; + String(const std::string &value) : std::string(value) {} +}; + +enum RFMode +{ + RF_DEFAULT = 0, + RF_CAL = 1, + RF_NO_CAL = 2, + RF_DISABLED = 4 +}; +#define WAKE_RF_DEFAULT RF_DEFAULT +#define WAKE_RF_DISABLED RF_DISABLED + +class EspClass +{ +public: + void deepSleep(uint64_t timeUs, RFMode mode = RF_DEFAULT); + bool rtcUserMemoryRead(uint32_t offset, uint32_t *data, size_t size); + bool rtcUserMemoryWrite(uint32_t offset, uint32_t *data, size_t size); + uint8_t getCpuFreqMHz(); +}; + +extern EspClass ESP; diff --git a/test/support/fakes/ArduinoOTA.h b/test/support/fakes/ArduinoOTA.h new file mode 100644 index 0000000..a030e88 --- /dev/null +++ b/test/support/fakes/ArduinoOTA.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include + +typedef enum +{ + OTA_AUTH_ERROR, + OTA_BEGIN_ERROR, + OTA_CONNECT_ERROR, + OTA_RECEIVE_ERROR, + OTA_END_ERROR +} ota_error_t; + +#define U_FLASH 0 +#define U_FS 100 + +class ArduinoOTAClass +{ +public: + void setHostname(const char *name); + void begin(bool useMDNS = true); + void handle(); + void onStart(std::function callback); + void onEnd(std::function callback); + void onError(std::function callback); + int getCommand(); +}; + +extern ArduinoOTAClass ArduinoOTA; diff --git a/test/support/fakes/ESP8266WiFi.h b/test/support/fakes/ESP8266WiFi.h new file mode 100644 index 0000000..4b5508a --- /dev/null +++ b/test/support/fakes/ESP8266WiFi.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include "IPAddress.h" + +typedef enum +{ + WL_NO_SHIELD = 255, + WL_IDLE_STATUS = 0, + WL_NO_SSID_AVAIL = 1, + WL_SCAN_COMPLETED = 2, + WL_CONNECTED = 3, + WL_CONNECT_FAILED = 4, + WL_CONNECTION_LOST = 5, + WL_WRONG_PASSWORD = 6, + WL_DISCONNECTED = 7 +} wl_status_t; + +typedef enum +{ + WIFI_OFF = 0, + WIFI_STA = 1, + WIFI_AP = 2, + WIFI_AP_STA = 3 +} WiFiMode_t; + +class ESP8266WiFiClass +{ +public: + bool mode(WiFiMode_t mode); + bool forceSleepBegin(uint32_t sleepUs = 0); + bool forceSleepWake(); + void persistent(bool persistent); + bool config(IPAddress local, IPAddress dns, IPAddress gateway, IPAddress subnet); + bool hostname(const char *name); + wl_status_t begin(const char *ssid, const char *passphrase = nullptr, int32_t channel = 0, + const uint8_t *bssid = nullptr, bool connect = true); + wl_status_t status(); + bool disconnect(bool wifiOff = false); + int32_t channel(); + uint8_t *BSSID(); + IPAddress localIP(); + String macAddress(); +}; + +extern ESP8266WiFiClass WiFi; + +class WiFiClient +{ +}; diff --git a/test/support/fakes/IPAddress.h b/test/support/fakes/IPAddress.h new file mode 100644 index 0000000..7b81ed3 --- /dev/null +++ b/test/support/fakes/IPAddress.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class IPAddress +{ +public: + IPAddress() : value(0) {} + IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) + : value(((uint32_t)a << 24) | ((uint32_t)b << 16) | ((uint32_t)c << 8) | d) {} + + uint32_t value; +}; diff --git a/test/support/fakes/PubSubClient.h b/test/support/fakes/PubSubClient.h new file mode 100644 index 0000000..a1cf575 --- /dev/null +++ b/test/support/fakes/PubSubClient.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +class PubSubClient +{ +public: + explicit PubSubClient(WiFiClient &) {} + PubSubClient &setServer(const char *domain, uint16_t port); + bool connect(const char *id, const char *user, const char *pass); + bool connected(); + bool loop(); + bool publish(const char *topic, const char *payload); +}; diff --git a/test/support/fakes/config.h b/test/support/fakes/config.h new file mode 100644 index 0000000..c3d5f9c --- /dev/null +++ b/test/support/fakes/config.h @@ -0,0 +1,25 @@ +// Fixture for the native tests; same shape as include/config.h.example. +// A real include/config.h takes precedence, and the tests do not depend on these values. +#include + +// WiFi settings +#define WLAN_SSID "TestWifi" +#define WLAN_PASSWD "testWifiPassword" + +// Network settings +IPAddress gateway(192, 0, 2, 1); +IPAddress subnet(255, 255, 255, 0); +IPAddress dns(192, 0, 2, 1); + +// MQTT broker settings +#define MQTT_BROKER "192.0.2.10" +#define MQTT_PORT 1883 +#define MQTT_USER "user" +#define MQTT_PASSWORD "password" + +// MQTT client settings +#define MQTT_CLIENT "esp-temperature-test" +#define MQTT_TOPIC "sensor/esp_temperature_test" + +// Static IP of the ESPtemp +IPAddress ip(192, 0, 2, 2); diff --git a/test/support/fakes/fake.h b/test/support/fakes/fake.h new file mode 100644 index 0000000..abf144b --- /dev/null +++ b/test/support/fakes/fake.h @@ -0,0 +1,88 @@ +#pragma once + +// Test doubles for the ESP8266 Arduino core and the libraries ESPtemp uses, +// so that src/main.cpp runs unchanged on the build machine. + +#include +#include +#include +#include + +namespace fake +{ + // ESP.deepSleep() never returns on the chip; here it unwinds the boot. + struct DeepSleep + { + uint64_t us; + int mode; + }; + + // How the world behaves. Survives deep sleep. + struct World + { + long wifiConnectAfterMs = 100; // after WiFi.begin(); negative: never + bool quickConnectWorks = true; // with the saved channel and BSSID + int wifiFailStatus = 7; // what WiFi.status() says until connected (WL_DISCONNECTED) + uint8_t channel = 6; + uint8_t bssid[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + + bool mqttReachable = true; + unsigned long mqttConnectCostMs = 50; + + float temperature = 21.46f; + float humidity = 45.34f; + uint16_t adc = 1000; + uint32_t highInputs = 0; // bit n: digitalRead(n) is HIGH + bool rtcReadFails = false; + }; + + // What one boot did. Starts empty after every wake. + struct Boot + { + unsigned long now = 0; + + int pinModes[32] = {}; + int pinLevels[32] = {}; + int pinHighWrites[32] = {}; + int pinLowWrites[32] = {}; + int rtcWrites = 0; + + int wifiMode = -1; + bool wifiBegun = false; + bool wifiQuick = false; + unsigned long wifiBegunAt = 0; + int wifiBegins = 0; + int quickBegins = 0; + int quickChannel = -1; + uint8_t quickBssid[6] = {}; + std::string ssid; + std::string hostname; + bool persistent = true; + uint32_t ip = 0; + int disconnects = 0; + + std::string mqttServer; + uint16_t mqttPort = 0; + std::string clientId; + int mqttConnects = 0; + bool mqttConnected = false; + std::string topic; + std::string payload; + int publishes = 0; + + std::string otaHostname; + bool otaBegun = false; + long otaHandles = 0; + + int htuBegins = 0; + }; + + extern World world; + extern Boot boot; + extern uint8_t rtc[512]; + + // Power on: a new world, empty RTC memory. + void reset(); + // Wake from deep sleep: RTC memory and the world stay. + void newBoot(); +} diff --git a/test/support/fakes/fakes.cpp b/test/support/fakes/fakes.cpp new file mode 100644 index 0000000..6eb1b04 --- /dev/null +++ b/test/support/fakes/fakes.cpp @@ -0,0 +1,216 @@ +#include "fake.h" + +#include +#include +#include +#include +#include + +namespace fake +{ + World world; + Boot boot; + uint8_t rtc[512]; + + void reset() + { + world = World(); + boot = Boot(); + memset(rtc, 0, sizeof(rtc)); + } + + void newBoot() + { + boot = Boot(); + } +} + +using fake::boot; +using fake::world; + +EspClass ESP; +ESP8266WiFiClass WiFi; +ArduinoOTAClass ArduinoOTA; + +// Arduino core + +unsigned long millis() { return boot.now; } + +void delay(unsigned long ms) { boot.now += ms; } + +void pinMode(uint8_t pin, uint8_t mode) { boot.pinModes[pin] = mode; } + +void digitalWrite(uint8_t pin, uint8_t value) +{ + boot.pinLevels[pin] = value; + if (value == HIGH) + { + boot.pinHighWrites[pin]++; + } + else + { + boot.pinLowWrites[pin]++; + } +} + +int digitalRead(uint8_t pin) { return (world.highInputs >> pin) & 1 ? HIGH : LOW; } + +int analogRead(uint8_t) { return world.adc; } + +void EspClass::deepSleep(uint64_t timeUs, RFMode mode) +{ + throw fake::DeepSleep{timeUs, mode}; +} + +bool EspClass::rtcUserMemoryRead(uint32_t offset, uint32_t *data, size_t size) +{ + if (world.rtcReadFails || offset * 4 + size > sizeof(fake::rtc)) + { + return false; + } + memcpy(data, fake::rtc + offset * 4, size); + return true; +} + +bool EspClass::rtcUserMemoryWrite(uint32_t offset, uint32_t *data, size_t size) +{ + if (offset * 4 + size > sizeof(fake::rtc)) + { + return false; + } + memcpy(fake::rtc + offset * 4, data, size); + boot.rtcWrites++; + return true; +} + +uint8_t EspClass::getCpuFreqMHz() { return 160; } + +// ESP8266WiFi + +bool ESP8266WiFiClass::mode(WiFiMode_t mode) +{ + boot.wifiMode = mode; + return true; +} + +bool ESP8266WiFiClass::forceSleepBegin(uint32_t) { return true; } + +bool ESP8266WiFiClass::forceSleepWake() { return true; } + +void ESP8266WiFiClass::persistent(bool persistent) { boot.persistent = persistent; } + +bool ESP8266WiFiClass::config(IPAddress local, IPAddress, IPAddress, IPAddress) +{ + boot.ip = local.value; + return true; +} + +bool ESP8266WiFiClass::hostname(const char *name) +{ + boot.hostname = name; + return true; +} + +wl_status_t ESP8266WiFiClass::begin(const char *ssid, const char *, int32_t channel, const uint8_t *bssid, bool) +{ + boot.ssid = ssid; + boot.wifiBegun = true; + boot.wifiBegunAt = boot.now; + boot.wifiBegins++; + boot.wifiQuick = bssid != nullptr; + if (boot.wifiQuick) + { + boot.quickBegins++; + boot.quickChannel = channel; + memcpy(boot.quickBssid, bssid, 6); + } + return status(); +} + +wl_status_t ESP8266WiFiClass::status() +{ + if (!boot.wifiBegun || boot.wifiMode != WIFI_STA) + { + return WL_DISCONNECTED; + } + bool reachable = !boot.wifiQuick || world.quickConnectWorks; + if (reachable && world.wifiConnectAfterMs >= 0 && + boot.now - boot.wifiBegunAt >= (unsigned long)world.wifiConnectAfterMs) + { + return WL_CONNECTED; + } + return (wl_status_t)world.wifiFailStatus; +} + +bool ESP8266WiFiClass::disconnect(bool) +{ + boot.wifiBegun = false; + boot.disconnects++; + return true; +} + +int32_t ESP8266WiFiClass::channel() { return world.channel; } + +uint8_t *ESP8266WiFiClass::BSSID() { return world.bssid; } + +IPAddress ESP8266WiFiClass::localIP() { return IPAddress(192, 0, 2, 2); } + +String ESP8266WiFiClass::macAddress() { return "02:00:00:00:00:01"; } + +// PubSubClient + +PubSubClient &PubSubClient::setServer(const char *domain, uint16_t port) +{ + boot.mqttServer = domain; + boot.mqttPort = port; + return *this; +} + +bool PubSubClient::connect(const char *id, const char *, const char *) +{ + boot.clientId = id; + boot.mqttConnects++; + boot.now += world.mqttConnectCostMs; + boot.mqttConnected = world.mqttReachable && WiFi.status() == WL_CONNECTED; + return boot.mqttConnected; +} + +bool PubSubClient::connected() { return boot.mqttConnected; } + +bool PubSubClient::loop() { return boot.mqttConnected; } + +bool PubSubClient::publish(const char *topic, const char *payload) +{ + boot.topic = topic; + boot.payload = payload; + boot.publishes++; + return boot.mqttConnected; +} + +// Adafruit_HTU21DF + +bool Adafruit_HTU21DF::begin() +{ + boot.htuBegins++; + return true; +} + +float Adafruit_HTU21DF::readTemperature() { return world.temperature; } + +float Adafruit_HTU21DF::readHumidity() { return world.humidity; } + +// ArduinoOTA + +void ArduinoOTAClass::setHostname(const char *name) { boot.otaHostname = name; } + +void ArduinoOTAClass::begin(bool) { boot.otaBegun = true; } + +void ArduinoOTAClass::handle() { boot.otaHandles++; } + +void ArduinoOTAClass::onStart(std::function) {} + +void ArduinoOTAClass::onEnd(std::function) {} + +void ArduinoOTAClass::onError(std::function) {} + +int ArduinoOTAClass::getCommand() { return U_FLASH; } diff --git a/test/test_esptemp/harness.cpp b/test/test_esptemp/harness.cpp new file mode 100644 index 0000000..798b95b --- /dev/null +++ b/test/test_esptemp/harness.cpp @@ -0,0 +1,62 @@ +#include "harness.h" + +#include + +void setUp() { fake::reset(); } + +void tearDown() {} + +Wake keepRunning(long maxLoops) +{ + for (long loops = 1; loops <= maxLoops; loops++) + { + try + { + loop(); + } + catch (const fake::DeepSleep &sleep) + { + return {true, sleep.us, sleep.mode, fake::boot.now, loops}; + } + fake::boot.now += 1; + } + return {false, 0, 0, fake::boot.now, maxLoops}; +} + +Wake wake(long maxLoops) +{ + fake::newBoot(); + try + { + setup(); + } + catch (const fake::DeepSleep &sleep) + { + return {true, sleep.us, sleep.mode, fake::boot.now, 0}; + } + return keepRunning(maxLoops); +} + +RtcData savedRtc() +{ + RtcData data; + memcpy(&data, fake::rtc, sizeof(data)); + return data; +} + +void saveRtc(RtcData data) +{ + data.crc32 = calculateCRC32(((uint8_t *)&data) + 4, sizeof(data) - 4); + memcpy(fake::rtc, &data, sizeof(data)); +} + +RtcData knownAccessPoint(uint8_t failures, uint16_t sleepsLeft) +{ + RtcData data = {}; + data.channel = fake::world.channel; + memcpy(data.ap_mac, fake::world.bssid, 6); + data.apValid = 1; + data.failures = failures; + data.sleepsLeft = sleepsLeft; + return data; +} diff --git a/test/test_esptemp/harness.h b/test/test_esptemp/harness.h new file mode 100644 index 0000000..f1cae92 --- /dev/null +++ b/test/test_esptemp/harness.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "rtc_data.h" + +// From src/main.cpp +void setup(); +void loop(); +uint32_t calculateCRC32(const uint8_t *data, size_t length); + +struct Wake +{ + bool slept; // false: still awake when the loop limit was reached (OTA wait loop) + uint64_t us; // deep sleep time, 0 is forever + int mode; // RFMode of the deep sleep + unsigned long at; // millis() when it went to sleep + long loops; // loop() calls in this run +}; + +// A wake from deep sleep: setup(), then loop() until the next deep sleep. +Wake wake(long maxLoops = 100000); +// More loop() calls in the same boot. +Wake keepRunning(long maxLoops); + +RtcData savedRtc(); +// Stores `data` in RTC memory with a valid CRC. +void saveRtc(RtcData data); +// Data for an access point the device has connected to before. +RtcData knownAccessPoint(uint8_t failures = 0, uint16_t sleepsLeft = 0); diff --git a/test/test_esptemp/runner.cpp b/test/test_esptemp/runner.cpp new file mode 100644 index 0000000..89b9b30 --- /dev/null +++ b/test/test_esptemp/runner.cpp @@ -0,0 +1,12 @@ +#include + +void runBackoffTests(); +void runFirmwareTests(); + +int main() +{ + UNITY_BEGIN(); + runBackoffTests(); + runFirmwareTests(); + return UNITY_END(); +} diff --git a/test/test_esptemp/test_backoff.cpp b/test/test_esptemp/test_backoff.cpp new file mode 100644 index 0000000..022cd89 --- /dev/null +++ b/test/test_esptemp/test_backoff.cpp @@ -0,0 +1,44 @@ +#include +#include + +static void test_backoff_doubles_from_one_period_up_to_the_cap() +{ + const uint16_t cap = backoffCap(7200e6, 300e6); + TEST_ASSERT_EQUAL_UINT16(24, cap); + TEST_ASSERT_EQUAL_UINT16(1, backoffSleeps(0, cap)); + TEST_ASSERT_EQUAL_UINT16(2, backoffSleeps(1, cap)); + TEST_ASSERT_EQUAL_UINT16(4, backoffSleeps(2, cap)); + TEST_ASSERT_EQUAL_UINT16(8, backoffSleeps(3, cap)); + TEST_ASSERT_EQUAL_UINT16(16, backoffSleeps(4, cap)); + TEST_ASSERT_EQUAL_UINT16(24, backoffSleeps(5, cap)); + TEST_ASSERT_EQUAL_UINT16(24, backoffSleeps(6, cap)); + TEST_ASSERT_EQUAL_UINT16(24, backoffSleeps(255, cap)); +} + +static void test_backoff_cap_counts_whole_periods_and_is_at_least_one() +{ + TEST_ASSERT_EQUAL_UINT16(24, backoffCap(7400e6, 300e6)); + TEST_ASSERT_EQUAL_UINT16(1, backoffCap(300e6, 300e6)); + TEST_ASSERT_EQUAL_UINT16(1, backoffCap(100e6, 300e6)); + TEST_ASSERT_EQUAL_UINT16(1, backoffCap(-1, 300e6)); + TEST_ASSERT_EQUAL_UINT16(1, backoffCap(7200e6, 0)); + TEST_ASSERT_EQUAL_UINT16(1, backoffCap(7200e6, -300e6)); + TEST_ASSERT_EQUAL_UINT16(65535, backoffCap(1e12, 1)); +} + +static void test_backoff_with_the_smallest_and_largest_caps() +{ + TEST_ASSERT_EQUAL_UINT16(1, backoffSleeps(0, 0)); + TEST_ASSERT_EQUAL_UINT16(1, backoffSleeps(3, 0)); + TEST_ASSERT_EQUAL_UINT16(1, backoffSleeps(3, 1)); + TEST_ASSERT_EQUAL_UINT16(32768, backoffSleeps(15, 65535)); + TEST_ASSERT_EQUAL_UINT16(65535, backoffSleeps(16, 65535)); + TEST_ASSERT_EQUAL_UINT16(10, backoffSleeps(16, 10)); +} + +void runBackoffTests() +{ + RUN_TEST(test_backoff_doubles_from_one_period_up_to_the_cap); + RUN_TEST(test_backoff_cap_counts_whole_periods_and_is_at_least_one); + RUN_TEST(test_backoff_with_the_smallest_and_largest_caps); +} diff --git a/test/test_esptemp/test_firmware.cpp b/test/test_esptemp/test_firmware.cpp new file mode 100644 index 0000000..9278f59 --- /dev/null +++ b/test/test_esptemp/test_firmware.cpp @@ -0,0 +1,396 @@ +#include +#include +#include + +#include + +#include "harness.h" + +extern IPAddress ip; // config.h, compiled into src/main.cpp + +static const uint64_t PERIOD = 300e6; // SLEEPTIME +static const uint8_t OTA_PIN = 14; +static const uint8_t SENSOR_VCC = 15; + +static StaticJsonDocument<512> published() +{ + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, fake::boot.payload); + TEST_ASSERT_FALSE_MESSAGE(error, fake::boot.payload.c_str()); + return doc; +} + +static void assertSleptOnePeriodWithoutRadio(const Wake &result) +{ + TEST_ASSERT_TRUE(result.slept); + TEST_ASSERT_EQUAL_UINT64(PERIOD, result.us); + TEST_ASSERT_EQUAL_INT(WAKE_RF_DISABLED, result.mode); +} + +static void test_crc32_is_crc32_mpeg2() +{ + TEST_ASSERT_EQUAL_HEX32(0x0376E6E7, calculateCRC32((const uint8_t *)"123456789", 9)); + TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFF, calculateCRC32((const uint8_t *)"", 0)); +} + +static void test_first_boot_connects_regularly_publishes_and_saves_the_access_point() +{ + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_EQUAL_INT(1, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_INT(0, fake::boot.quickBegins); + TEST_ASSERT_EQUAL_INT(WIFI_STA, fake::boot.wifiMode); + TEST_ASSERT_FALSE(fake::boot.persistent); + TEST_ASSERT_EQUAL_HEX32(ip.value, fake::boot.ip); + TEST_ASSERT_FALSE(fake::boot.ssid.empty()); + TEST_ASSERT_FALSE(fake::boot.hostname.empty()); + TEST_ASSERT_EQUAL_STRING(fake::boot.hostname.c_str(), fake::boot.clientId.c_str()); + TEST_ASSERT_FALSE(fake::boot.mqttServer.empty()); + TEST_ASSERT_NOT_EQUAL(0, fake::boot.mqttPort); + TEST_ASSERT_FALSE(fake::boot.otaBegun); + TEST_ASSERT_GREATER_OR_EQUAL(1, fake::boot.disconnects); + + // The sensor gets power only while it is read + TEST_ASSERT_EQUAL_INT(1, fake::boot.htuBegins); + TEST_ASSERT_EQUAL_INT(1, fake::boot.pinHighWrites[SENSOR_VCC]); + TEST_ASSERT_EQUAL_INT(LOW, fake::boot.pinLevels[SENSOR_VCC]); + + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); + TEST_ASSERT_FALSE(fake::boot.topic.empty()); + StaticJsonDocument<512> doc = published(); + TEST_ASSERT_FLOAT_WITHIN(0.001, 21.5, doc["temperature"].as()); + TEST_ASSERT_FLOAT_WITHIN(0.001, 45.3, doc["humidity"].as()); + TEST_ASSERT_FLOAT_WITHIN(0.001, 4.0, doc["batteryVoltage"].as()); + TEST_ASSERT_EQUAL_INT(85, doc["batteryPercentage"].as()); + TEST_ASSERT_EQUAL_STRING("v1.2", doc["firmwareVersion"].as()); + TEST_ASSERT_EQUAL_INT(0, doc["connectFailures"].as()); + + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(1, rtc.apValid); + TEST_ASSERT_EQUAL_UINT8(fake::world.channel, rtc.channel); + TEST_ASSERT_EQUAL_HEX8_ARRAY(fake::world.bssid, rtc.ap_mac, 6); + TEST_ASSERT_EQUAL_UINT8(0, rtc.failures); + TEST_ASSERT_EQUAL_UINT16(0, rtc.sleepsLeft); +} + +static void test_known_access_point_connects_quickly() +{ + saveRtc(knownAccessPoint()); + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_LESS_THAN(1000, result.at); + TEST_ASSERT_EQUAL_INT(1, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_INT(1, fake::boot.quickBegins); + TEST_ASSERT_EQUAL_INT(fake::world.channel, fake::boot.quickChannel); + TEST_ASSERT_EQUAL_HEX8_ARRAY(fake::world.bssid, fake::boot.quickBssid, 6); + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); +} + +static void test_quick_connect_falls_back_to_regular_after_5s_and_saves_the_new_access_point() +{ + RtcData old = knownAccessPoint(); + old.channel = 11; + old.ap_mac[5] = 0x99; + saveRtc(old); + fake::world.quickConnectWorks = false; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_GREATER_THAN(5000, result.at); + TEST_ASSERT_EQUAL_INT(2, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_INT(1, fake::boot.quickBegins); + TEST_ASSERT_EQUAL_INT(11, fake::boot.quickChannel); + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(fake::world.channel, rtc.channel); + TEST_ASSERT_EQUAL_HEX8_ARRAY(fake::world.bssid, rtc.ap_mac, 6); +} + +static void test_corrupt_rtc_counts_as_a_first_boot() +{ + saveRtc(knownAccessPoint(3, 5)); + fake::rtc[5] ^= 0xFF; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_EQUAL_INT(1, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_INT(0, fake::boot.quickBegins); + TEST_ASSERT_EQUAL_INT(0, published()["connectFailures"].as()); +} + +static void test_unreadable_rtc_counts_as_a_first_boot() +{ + saveRtc(knownAccessPoint(0, 3)); + fake::world.rtcReadFails = true; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_EQUAL_INT(0, fake::boot.quickBegins); + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); +} + +static void test_wifi_timeout_counts_a_failure_and_waits_two_periods() +{ + fake::world.wifiConnectAfterMs = -1; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_GREATER_THAN(20000, result.at); + TEST_ASSERT_LESS_THAN(20100, result.at); + TEST_ASSERT_EQUAL_INT(0, fake::boot.publishes); + TEST_ASSERT_GREATER_OR_EQUAL(1, fake::boot.disconnects); + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(1, rtc.failures); + TEST_ASSERT_EQUAL_UINT16(1, rtc.sleepsLeft); + TEST_ASSERT_EQUAL_UINT8(0, rtc.apValid); +} + +static void test_failures_back_off_10_20_40_80_120_120_minutes() +{ + fake::world.wifiConnectAfterMs = -1; + std::vector attempts; + + for (int sleeps = 0; sleeps < 200 && attempts.size() < 7; sleeps++) + { + Wake result = wake(); + assertSleptOnePeriodWithoutRadio(result); // no single sleep is ever longer + if (fake::boot.wifiBegins > 0) + { + attempts.push_back(sleeps); + } + else + { + // A wake that only sits out the wait powers neither the sensor nor WiFi + TEST_ASSERT_EQUAL_UINT32(0, result.at); + TEST_ASSERT_EQUAL_INT(0, fake::boot.htuBegins); + TEST_ASSERT_EQUAL_INT(0, fake::boot.pinHighWrites[SENSOR_VCC]); + TEST_ASSERT_EQUAL_INT(WIFI_OFF, fake::boot.wifiMode); + } + } + + // 5-minute periods between two attempts + const int expected[] = {2, 4, 8, 16, 24, 24}; + TEST_ASSERT_EQUAL_size_t(7, attempts.size()); + for (size_t n = 0; n < 6; n++) + { + TEST_ASSERT_EQUAL_INT(expected[n], attempts[n + 1] - attempts[n]); + } +} + +static void test_skip_wake_counts_down_then_tries_again() +{ + saveRtc(knownAccessPoint(1, 1)); + + Wake skipped = wake(); + assertSleptOnePeriodWithoutRadio(skipped); + TEST_ASSERT_EQUAL_INT(0, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_UINT16(0, savedRtc().sleepsLeft); + TEST_ASSERT_EQUAL_UINT8(1, savedRtc().failures); + + Wake tried = wake(); + assertSleptOnePeriodWithoutRadio(tried); + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); +} + +static void test_recovery_reports_the_failures_once_and_resets_them() +{ + saveRtc(knownAccessPoint(3, 0)); + + wake(); + TEST_ASSERT_EQUAL_INT(3, published()["connectFailures"].as()); + TEST_ASSERT_EQUAL_UINT8(0, savedRtc().failures); + TEST_ASSERT_EQUAL_UINT16(0, savedRtc().sleepsLeft); + + Wake next = wake(); + assertSleptOnePeriodWithoutRadio(next); + TEST_ASSERT_EQUAL_INT(0, published()["connectFailures"].as()); +} + +static void test_failure_counter_saturates_at_255() +{ + saveRtc(knownAccessPoint(255, 0)); + fake::world.wifiConnectAfterMs = -1; + + wake(); + + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(255, rtc.failures); + TEST_ASSERT_EQUAL_UINT16(23, rtc.sleepsLeft); + TEST_ASSERT_EQUAL_UINT8(1, rtc.apValid); // the access point is kept +} + +static void test_refused_regular_connection_gives_up_at_once() +{ + const int refusals[] = {WL_NO_SSID_AVAIL, WL_CONNECT_FAILED, WL_WRONG_PASSWORD}; + for (int status : refusals) + { + fake::reset(); + fake::world.wifiConnectAfterMs = -1; + fake::world.wifiFailStatus = status; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_LESS_THAN(10, result.at); + TEST_ASSERT_EQUAL_UINT8(1, savedRtc().failures); + } +} + +static void test_refused_quick_connection_still_falls_back_to_regular() +{ + saveRtc(knownAccessPoint()); + fake::world.quickConnectWorks = false; + fake::world.wifiConnectAfterMs = -1; + fake::world.wifiFailStatus = WL_NO_SSID_AVAIL; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_GREATER_THAN(5000, result.at); + TEST_ASSERT_LESS_THAN(5100, result.at); + TEST_ASSERT_EQUAL_INT(2, fake::boot.wifiBegins); + TEST_ASSERT_EQUAL_UINT8(1, savedRtc().failures); +} + +static void test_unreachable_broker_counts_a_failure() +{ + fake::world.mqttReachable = false; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + TEST_ASSERT_GREATER_THAN(25000, result.at); + TEST_ASSERT_LESS_THAN(25100, result.at); + TEST_ASSERT_GREATER_THAN(1, fake::boot.mqttConnects); + TEST_ASSERT_EQUAL_INT(0, fake::boot.publishes); + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(1, rtc.failures); + TEST_ASSERT_EQUAL_UINT16(1, rtc.sleepsLeft); +} + +static void test_low_battery_sleeps_for_good() +{ + fake::world.adc = 825; // 3.3 V + + Wake result = wake(); + + TEST_ASSERT_TRUE(result.slept); + TEST_ASSERT_EQUAL_UINT64(0, result.us); + TEST_ASSERT_EQUAL_INT(WAKE_RF_DEFAULT, result.mode); + TEST_ASSERT_EQUAL_INT(0, fake::boot.publishes); +} + +static void test_battery_just_above_the_cutoff_keeps_going() +{ + fake::world.adc = 826; + + Wake result = wake(); + + assertSleptOnePeriodWithoutRadio(result); + StaticJsonDocument<512> doc = published(); + TEST_ASSERT_FLOAT_WITHIN(0.001, 3.3, doc["batteryVoltage"].as()); + TEST_ASSERT_EQUAL_INT(0, doc["batteryPercentage"].as()); +} + +static void test_battery_percentage_is_clamped_to_0_and_100() +{ + const struct + { + uint16_t adc; + int percentage; + } cases[] = {{850, 0}, {862, 0}, {863, 0}, {879, 10}, {1022, 99}, {1023, 100}, {1024, 100}}; + for (const auto &c : cases) + { + fake::reset(); + fake::world.adc = c.adc; + + wake(); + + TEST_ASSERT_EQUAL_INT_MESSAGE(c.percentage, published()["batteryPercentage"].as(), fake::boot.payload.c_str()); + } +} + +static void test_payload_rounds_and_fits_the_longest_values() +{ + saveRtc(knownAccessPoint(255, 0)); + fake::world.temperature = -12.34f; + fake::world.humidity = 99.96f; + fake::world.adc = 1024; + + wake(); + + TEST_ASSERT_LESS_THAN(191, fake::boot.payload.size()); + StaticJsonDocument<512> doc = published(); + TEST_ASSERT_FLOAT_WITHIN(0.001, -12.3, doc["temperature"].as()); + TEST_ASSERT_FLOAT_WITHIN(0.001, 100.0, doc["humidity"].as()); + TEST_ASSERT_FLOAT_WITHIN(0.001, 4.1, doc["batteryVoltage"].as()); + TEST_ASSERT_EQUAL_INT(100, doc["batteryPercentage"].as()); + TEST_ASSERT_EQUAL_INT(255, doc["connectFailures"].as()); + TEST_ASSERT_EQUAL_STRING("v1.2", doc["firmwareVersion"].as()); +} + +static void test_ota_mode_ignores_the_wait_and_stays_awake() +{ + saveRtc(knownAccessPoint(2, 3)); + fake::world.highInputs = 1u << OTA_PIN; + + Wake result = wake(3000); + + TEST_ASSERT_FALSE(result.slept); + TEST_ASSERT_EQUAL_INT(1, fake::boot.wifiBegins); + TEST_ASSERT_TRUE(fake::boot.otaBegun); + TEST_ASSERT_EQUAL_STRING(fake::boot.hostname.c_str(), fake::boot.otaHostname.c_str()); + TEST_ASSERT_GREATER_THAN(2000, fake::boot.otaHandles); + TEST_ASSERT_EQUAL_INT(1, fake::boot.publishes); + // The LED blinks + TEST_ASSERT_GREATER_THAN(0, fake::boot.pinHighWrites[LED_BUILTIN]); + TEST_ASSERT_GREATER_THAN(0, fake::boot.pinLowWrites[LED_BUILTIN]); + // A working connection in OTA mode ends the wait as well + RtcData rtc = savedRtc(); + TEST_ASSERT_EQUAL_UINT8(0, rtc.failures); + TEST_ASSERT_EQUAL_UINT16(0, rtc.sleepsLeft); +} + +static void test_ota_mode_checks_the_battery_every_1024_loops() +{ + fake::world.highInputs = 1u << OTA_PIN; + TEST_ASSERT_FALSE(wake(3000).slept); + + fake::world.adc = 800; + Wake result = keepRunning(2000); + + TEST_ASSERT_TRUE(result.slept); + TEST_ASSERT_EQUAL_UINT64(0, result.us); + TEST_ASSERT_LESS_OR_EQUAL(1024, result.loops); +} + +void runFirmwareTests() +{ + RUN_TEST(test_crc32_is_crc32_mpeg2); + RUN_TEST(test_first_boot_connects_regularly_publishes_and_saves_the_access_point); + RUN_TEST(test_known_access_point_connects_quickly); + RUN_TEST(test_quick_connect_falls_back_to_regular_after_5s_and_saves_the_new_access_point); + RUN_TEST(test_corrupt_rtc_counts_as_a_first_boot); + RUN_TEST(test_unreadable_rtc_counts_as_a_first_boot); + RUN_TEST(test_wifi_timeout_counts_a_failure_and_waits_two_periods); + RUN_TEST(test_failures_back_off_10_20_40_80_120_120_minutes); + RUN_TEST(test_skip_wake_counts_down_then_tries_again); + RUN_TEST(test_recovery_reports_the_failures_once_and_resets_them); + RUN_TEST(test_failure_counter_saturates_at_255); + RUN_TEST(test_refused_regular_connection_gives_up_at_once); + RUN_TEST(test_refused_quick_connection_still_falls_back_to_regular); + RUN_TEST(test_unreachable_broker_counts_a_failure); + RUN_TEST(test_low_battery_sleeps_for_good); + RUN_TEST(test_battery_just_above_the_cutoff_keeps_going); + RUN_TEST(test_battery_percentage_is_clamped_to_0_and_100); + RUN_TEST(test_payload_rounds_and_fits_the_longest_values); + RUN_TEST(test_ota_mode_ignores_the_wait_and_stays_awake); + RUN_TEST(test_ota_mode_checks_the_battery_every_1024_loops); +}