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); +}