/////////////////////////////////////////////////////////////////////////// // Copyright (C) Wizardry and Steamworks 2026 - License: GNU MIT // // Please see: http://www.gnu.org/licenses/gpl.html for legal details, // // rights of fair usage, the disclaimer and warranty conditions. // /////////////////////////////////////////////////////////////////////////// // The following template is written as a controller for the COLMi rings // // produced by the Chinese company with the same name[1]. COLMi rings // // are extremely affordable and the protocol has been reverse-engineered // // such that the rings can be accessed using standard Bluetooth Low- // // Energy (BLE) equipment whilst providing telemtry such as heartrate, // // accelerometer or oxygen concentration in the blood. This Arduino // // template follows the work of Aaron Christophel (atc1441) but by using // // an Arduino micro-controller to connect to the ring. The template is // // designed with the Wizardry and Steamworks IoT style in mind, by // // delegating all computation to the controller behind the Arduino and // // with the Arduino template only providing a thin control shim in order // // to be able to flexibly bridge automation software like Node-Red or // // Homeassistant with the COLMi ring. // // // // The template is known to work with COLMi R02 and COLMi R03 but the // // entire series up to R06 use the same communication protocol even if // // the hardware varies slightly. Similarly, the various models only tend // // to differ in build materials but seem to subscribe to the same API // // with the only mention that a COLMi ring that uses the BlueX chipset // // will allow more firmeare customization (something outside the scope // // of this template. // // // // This template is blended with the WiFi preboot template that can help // // setting up the connection to a WiFi AP conveniently. The template is // // designed to connect to a COLMi ring, request sensor data and then // // publish that telemetry to an MQTT server on the local network. // // // // Critical variables to configure are: // // * PREBOOT_MASTER_PASSWORD to be set to a global password // // * COLMI_RING_ADDRESS to be set to a COLMi ring BLE/MAC address // // * MQTT_* parameters must be configured to specify an MQTT broker // // * HOSTNAME the hostname of the current ESP device // // // // The full documentation can be found on: // // * https://grimore.org/arduino/colmi_ring // // // // [1] COLMi company page, retrieved Jan 2026 https://www.colmi.info/ // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // configurable parameters // /////////////////////////////////////////////////////////////////////////// // comment out to enable debugging //#define DEBUG 1 // set the master password for OTA updates and access to the soft AP #define PREBOOT_MASTER_PASSWORD "" // the name and length of the cookie to use for authentication #define PREBOOT_COOKIE_NAME "ArduinoPrebootCookie" #define PREBOOT_COOKIE_MAX_LENGTH 256 // timeout to establish STA connection in milliseconds #define WIFI_RETRY_TIMEOUT 1000 * 10 // retries as multiples of WIFI_RETRY_TIMEOUT milliseconds #define WIFI_CONNECT_TRIES 60 // how much time to wait for a client to reconfigure before switching to client mode again #define WIFI_SERVER_TIMEOUT 1000 * 60 * 3 // the time between blinking a single digit #define BLINK_DIT_LENGTH 500 // the time between blinking the whole number #define BLINK_DAH_LENGTH 2500 // used to reboot the board in case it is stuck after that many milliseconds #define WATCHDOG_TIMEOUT 15000 // scanner application // COLMI ring bluetooth address #define COLMI_RING_ADDRESS "" // The MQTT broker to connect to. #define MQTT_HOST "iot.internal" // The MQTT broker username. #define MQTT_USERNAME "" // The MQTT broker password. #define MQTT_PASSWORD "" // The MQTT broker port. #define MQTT_PORT 1883 // MQTT topic used to listen by this template #define MQTT_TOPIC "" // MQTT topic used to publish messages by this template #define MQTT_TOPIC_OUTBOX "messages" // The MQTT topic #define MQTT_CLIENT_ID "" // Maximal size of an MQTT payload #define MQTT_PAYLOAD_MAX_LENGTH MQTT_MAX_PACKET_SIZE // COLMI service definitions #define COLMI_SERVICE_UUID "6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E" #define COLMI_WRITE_UUID "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" #define COLMI_NOTIFY_UUID "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // COLMI packet definitions (16-byte packet) // last byte is a checksum calculated before sending unsigned char COLMI_START_RAW_DATA[16] = {0xA1, 0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}; unsigned char COLMI_REBOOT[16] = {0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}; // Platform specific defines. #if defined(ARDUINO_ARCH_ESP8266) #define GET_CHIP_ID() (ESP.getChipId()) #elif defined(ARDUINO_ARCH_ESP32) #define GET_CHIP_ID() ((uint16_t)(ESP.getEfuseMac() >> 32)) #ifdef ARDUINO_ESP32_DEV // This code only compiles if "ESP32 Dev Module" is selected #ifndef LED_BUILTIN #define LED_BUILTIN 2 #endif #endif #endif // The hostname to use. #define HOSTNAME() String("bleScanner") //String("esp-" + String(GET_CHIP_ID(), HEX)) #define CONFIGURATION_FILE_NAME "/config.json" #define CONFIGURATION_MAX_LENGTH 1024 /////////////////////////////////////////////////////////////////////////// // includes // /////////////////////////////////////////////////////////////////////////// #include #if defined(ARDUINO_ARCH_ESP32) #include #include "esp_mac.h" #include #include #elif defined(ESP8266) #include #include #include #endif #include #include #include #include #include // Arduino OTA #include #include #include // COLMi application #include #include #include /////////////////////////////////////////////////////////////////////////// // function definitions // /////////////////////////////////////////////////////////////////////////// byte* getHardwareAddress(void); char* getHardwareAddress(char colon); String generateTemporarySSID(void); void arduinoOtaTickCallback(void); void blinkDigitsDahTickCallback(void); void blinkDigitsDitTickCallback(void); void blinkDigitsBlinkTickCallback(void); void clientWifiTickCallback(void); void serverWifiTickCallback(void); void handleServerWifi(void); void handleClientWifi(void); bool setConfiguration(const char* configurationFile, JsonDocument &configuration); int getConfiguration(const char* configurationFile, JsonDocument &configuration); void handleRootHttpRequest(void); void handleRootCssRequest(void); void handleSetupHttpRequest(void); void handleRootHttpGet(void); void handleSetupHttpGet(void); void handleRootHttpPost(void); void handleSetupHttpPost(void); void handleHttpNotFound(void); void rebootTickCallback(void); // COLMI application // MQTT void mqttTickCallback(void); String getMqttTopic(void); String getMqttClientId(void); bool mqttConnect(void); void mqttCallback(char *topic, byte *payload, unsigned int length); constexpr unsigned int mqttColmiCommandHash(const char *s, int off = 0); // COLMI void processColmiPacket(unsigned char *pData, unsigned int length); uint8_t colmiPacketChecksum(uint8_t* data); bool nimBLEWriteCharacteristic(NimBLERemoteService* pSvc, NimBLEUUID characteristic, unsigned char *pData, unsigned int length); using nimBLENotify = std::function; void onNimBLENotify(NimBLERemoteCharacteristic* pRemoteCharacteristic, unsigned char *pData, unsigned int length, bool isNotify); NimBLERemoteCharacteristic *nimBLESubscribe(NimBLERemoteService *pSvc, NimBLEUUID characteristic, nimBLENotify callback); bool nimBLEUnsubscribe(NimBLERemoteService *pSvc, NimBLEUUID characteristic); NimBLERemoteService *nimBLEGetService(NimBLEClient *pClient, NimBLEUUID service); NimBLEClient *nimBLEConnect(NimBLEAddress nimBLEAddress, unsigned int timeout); void nimBLEDisconnect(NimBLEClient *pClient); int nimBLEQueryRssi(NimBLEClient *pClient); void printHex(const uint8_t* data, size_t length); void colmiTickCallback(void); // COLMI (custom prototypes) void colmiAccelerometer(int16_t accX, int16_t accY, int16_t accZ); void colmiHeartrate(int16_t ppg, int16_t ppg_max, int16_t ppg_min, int16_t ppg_diff); void colmiOxygen(int16_t spO2, int16_t spO2_max, int16_t spO2_min, int16_t spO2_diff); void colmiBattery(uint16_t batteryLevel, bool isCharging); void colmiIdle(void); void colmiTickCallback(void); void colmiNimBLEConnect(void); // COLMI-MQTT JsonDocument mqttColmiReboot(JsonDocument &doc); JsonDocument mqttColmiConnect(JsonDocument &doc); JsonDocument mqttColmiRssi(JsonDocument &doc); JsonDocument mqttColmiQueryRssi(JsonDocument &doc); /////////////////////////////////////////////////////////////////////////// // variable declarations // /////////////////////////////////////////////////////////////////////////// IPAddress softAPAddress(8, 8, 8, 8); IPAddress softAPNetmask(255, 255, 255, 0); DNSServer dnsServer; #if defined(ARDUINO_ARCH_ESP8266) ESP8266WebServer server(80); #elif defined(ARDUINO_ARCH_ESP32) WebServer server(80); #endif TickTwo arduinoOtaTick(arduinoOtaTickCallback, 1000); TickTwo rebootTick(rebootTickCallback, 1000); TickTwo clientWifiTick(clientWifiTickCallback, 25); TickTwo serverWifiTick(serverWifiTickCallback, 250); TickTwo blinkDigitsDahTick(blinkDigitsDahTickCallback, BLINK_DAH_LENGTH); TickTwo blinkDigitsDitTick(blinkDigitsDitTickCallback, BLINK_DIT_LENGTH); TickTwo blinkDigitsBlinkTick(blinkDigitsBlinkTickCallback, 25); enum bootMode : int { BOOT_MODE_NONE = 0, BOOT_MODE_CLIENT, BOOT_MODE_SERVER }; char *authenticationCookie = NULL; bool otaStarted; bool otaInProgress; bool networkConnected; int clientConnectionTries; bool rebootPending; int temporarySSIDLength; int temporarySSIDIndex; int *temporarySSIDNumbers; int blinkLedState; bool discoveryStarted; // COLMI application WiFiClient espClient; PubSubClient mqttClient(espClient); // COLMI application // Common UUIDs for Colmi R02 (may vary by firmware) NimBLEUUID COLMI_SERVICE(COLMI_SERVICE_UUID); NimBLEUUID COLMI_WRITE(COLMI_WRITE_UUID); NimBLEUUID COLMI_NOTIFY(COLMI_NOTIFY_UUID); NimBLEAddress COLMI_ADDRESS(COLMI_RING_ADDRESS, BLE_ADDR_PUBLIC); // COLMI application // MQTT String mqttTopicCache; String mqttClientIdCache; TickTwo mqttTick(mqttTickCallback, 250); // COLMI SemaphoreHandle_t nimBLEClientSemaphore = NULL; unsigned int nimBLELockTimeout = 10000; unsigned int nimBLEConnectTimeout = 1000; NimBLEClient *pClient = nullptr; NimBLERemoteService *pSvc = nullptr; NimBLERemoteCharacteristic *pChar = nullptr; // in 20md units, so 1000 = 10s unsigned int nimBLESupervisionTimeout = 100; bool nimBLEConnected = false; /////////////////////////////////////////////////////////////////////////// // HTML & CSS templates // /////////////////////////////////////////////////////////////////////////// const char *GENERIC_CSS_TEMPLATE PROGMEM = R"html( * { box-sizing: border-box; } body { background-color: #3498db; font-family: "Arial", sans-serif; padding: 50px; } .container { margin: 20px auto; padding: 10px; width: 300px; height: 100%; background-color: #fff; border-radius: 5px; margin-left: auto; margin-right: auto; } h1 { width: 70%; color: #777; font-size: 32px; margin: 28px auto; text-align: center; } form { text-align: center; } input { padding: 12px 0; margin-bottom: 10px; border-radius: 3px; border: 2px solid transparent; text-align: center; width: 90%; font-size: 16px; transition: border 0.2s, background-color 0.2s; } form .field { background-color: #ecf0f1; } form .field:focus { border: 2px solid #3498db; } form .btn { background-color: #3498db; color: #fff; line-height: 25px; cursor: pointer; } form .btn:hover, form .btn:active { background-color: #1f78b4; border: 2px solid #1f78b4; } .pass-link { text-align: center; } .pass-link a:link, .pass-link a:visited { font-size: 12px; color: #777; } table { border: 1px solid #dededf; border-collapse: collapse; border-spacing: 1px; margin-left: auto; margin-right: auto; width: 80%; } td { border: 1px solid #dededf; background-color: #ffffff; color: #000000; padding: 1em; } )html"; const char *HTML_SETUP_TEMPLATE PROGMEM = R"html( setup

setup

AP %AP%
MAC %MAC%


)html"; const char *HTML_AUTH_TEMPLATE PROGMEM = R"html( Preboot Access

admin

)html"; /////////////////////////////////////////////////////////////////////////// // class declarations // /////////////////////////////////////////////////////////////////////////// // client callbacks class ClientCallbacks : public NimBLEClientCallbacks { void onConnect(NimBLEClient* pClient) override { #ifdef DEBUG Serial.println("Client connected."); #endif nimBLEConnected = true; JsonDocument doc; doc["state"] = "connected"; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } void onDisconnect(NimBLEClient* pClient, int reason) override { #ifdef DEBUG Serial.print("Client disconnected with reason: "); switch(reason) { case 0x213: Serial.println("Remote User Terminated Connection"); break; case 0x23E: Serial.println("Connection Establishment Failed / LL Response Timeout"); break; case 0x222: Serial.println("LMP/LL Response Timeout"); break; case 0x22: Serial.println("Connection Fail for LMP Timeout"); break; case 0x208: Serial.println("Connection Timeout"); break; case 0x216: Serial.println("Connection Terminated By Local Host"); break; default: Serial.printf("Unknown (%x)", reason); break; } #endif nimBLEConnected = false; JsonDocument doc; doc["state"] = "disconnected"; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } void onConnectFail(NimBLEClient* pClient, int reason) override { #ifdef DEBUG Serial.println("Client connection failed."); #endif nimBLEConnected = false; JsonDocument doc; doc["state"] = "failed"; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } }; // scanner ClientCallbacks *clientCallbacks; /////////////////////////////////////////////////////////////////////////// // begin Arduino // /////////////////////////////////////////////////////////////////////////// void setup() { #ifdef DEBUG Serial.begin(115200); // wait for serial while (!Serial) { delay(100); } Serial.println(); #else Serial.end(); #endif #ifdef DEBUG Serial.println("Mounting filesystem..."); #endif #if defined(ARDUINO_ARCH_ESP8266) if (!LittleFS.begin()) { #ifdef DEBUG Serial.println("LittleFS mount failed, formatting and rebooting..."); #endif LittleFS.format(); delay(1000); ESP.restart(); #elif defined(ARDUINO_ARCH_ESP32) if (!LittleFS.begin(true)) { #endif #ifdef DEBUG Serial.println("LittleFS mount & format failed..."); #endif return; } #ifdef DEBUG Serial.printf("Checking if WiFi server must be started...\n"); #endif // check if ssid is set and start soft AP or STA mode JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif delay(60000); ESP.restart(); return; } switch(configuration["boot"].as()) { case BOOT_MODE_CLIENT: #ifdef DEBUG Serial.printf("Client connecting to WiFi...\n"); #endif //wifiOnEventId = WiFi.onEvent(clientWifiCallback); clientWifiTick.start(); break; case BOOT_MODE_SERVER: case BOOT_MODE_NONE: #ifdef DEBUG Serial.printf("Server AP starting...\n"); #endif // start soft AP rebootTick.start(); serverWifiTick.start(); break; } // setup OTA ArduinoOTA.setHostname(configuration["name"].as()); // allow flashing with the master password ArduinoOTA.setPassword(PREBOOT_MASTER_PASSWORD); ArduinoOTA.onStart([]() { // mark OTA as started otaInProgress = true; // stop LittleFS as per the documentation LittleFS.end(); String type; if (ArduinoOTA.getCommand() == U_FLASH) { type = "sketch"; } else { // U_FS type = "filesystem"; } // NOTE: if updating FS this would be the place to unmount FS using FS.end() #ifdef DEBUG Serial.println("Start updating " + type); #endif }); ArduinoOTA.onEnd([]() { otaInProgress = false; #ifdef DEBUG Serial.println("\nEnd"); #endif // restart the device #ifdef DEBUG Serial.printf("Restarting ESP.\n"); #endif delay(1000); ESP.restart(); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { #ifdef DEBUG Serial.printf("Progress: %u%%\r", (progress / (total / 100))); #endif }); ArduinoOTA.onError([](ota_error_t error) { #ifdef DEBUG Serial.printf("Error[%u]: ", error); #endif if (error == OTA_AUTH_ERROR) { #ifdef DEBUG Serial.println("Auth Failed"); #endif } else if (error == OTA_BEGIN_ERROR) { #ifdef DEBUG Serial.println("Begin Failed"); #endif } else if (error == OTA_CONNECT_ERROR) { #ifdef DEBUG Serial.println("Connect Failed"); #endif } else if (error == OTA_RECEIVE_ERROR) { #ifdef DEBUG Serial.println("Receive Failed"); #endif } else if (error == OTA_END_ERROR) { #ifdef DEBUG Serial.println("End Failed"); #endif } }); // hang watchdog esp_task_wdt_config_t wdt_config = { // Timeout in milliseconds .timeout_ms = WATCHDOG_TIMEOUT, // Monitor Idle tasks on both cores .idle_core_mask = (1 << 0) | (1 << 1), // Restart ESP32 on timeout .trigger_panic = true }; esp_task_wdt_init(&wdt_config); esp_task_wdt_add(NULL); // Add current task (loop) to watchdog // start timers / threads arduinoOtaTick.start(); rebootTick.start(); // COLMI application // initialize semaphore Serial.println("Allocating Semaphore."); nimBLEClientSemaphore = xSemaphoreCreateBinary(); if(nimBLEClientSemaphore == NULL) { Serial.println("Could not create semaphore."); sleep(60000); ESP.restart(); return; } xSemaphoreGive(nimBLEClientSemaphore); // initialize BLE Serial.println("Initializing BLE."); NimBLEDevice::init("[WaS] COLMI"); mqttTick.start(); } void loop() { esp_task_wdt_reset(); arduinoOtaTick.update(); rebootTick.update(); clientWifiTick.update(); serverWifiTick.update(); blinkDigitsDitTick.update(); blinkDigitsDahTick.update(); blinkDigitsBlinkTick.update(); // scanner application mqttTick.update(); } /////////////////////////////////////////////////////////////////////////// // end Arduino // /////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// // MQTT-scanner // /////////////////////////////////////////////////////////////////////////// constexpr unsigned int mqttColmiCommandHash(const char *s, int off) { return !s[off] ? 5381 : (mqttColmiCommandHash(s, off+1)*33) ^ s[off]; } void printHex(const uint8_t* data, size_t length) { for (size_t i = 0; i < length; i++) { if (data[i] < 0x10) { Serial.print("0"); // Leading zero for single hex digits } Serial.print(data[i], HEX); Serial.print(" "); } Serial.println(); } uint8_t colmiPacketChecksum(uint8_t* data) { uint16_t sum = 0; for (int i = 0; i < 15; i++) { sum += data[i]; } return sum % 255; } void colmiAccelerometer(int16_t accX, int16_t accY, int16_t accZ) { JsonDocument doc; doc["data"]["accelerometer"]["x"] = accX; doc["data"]["accelerometer"]["y"] = accY; doc["data"]["accelerometer"]["z"] = accZ; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } void colmiHeartrate(int16_t ppg, int16_t ppg_max, int16_t ppg_min, int16_t ppg_diff) { JsonDocument doc; doc["data"]["heartrate"]["ppg"] = ppg; doc["data"]["heartrate"]["ppg_max"] = ppg_max; doc["data"]["heartrate"]["ppg_min"] = ppg_min; doc["data"]["heartrate"]["ppg_diff"] = ppg_diff; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } void colmiOxygen(int16_t spO2, int16_t spO2_max, int16_t spO2_min, int16_t spO2_diff) { JsonDocument doc; doc["data"]["oxygen"]["spO2"] = spO2; doc["data"]["oxygen"]["spO2_max"] = spO2_max; doc["data"]["oxygen"]["spO2_min"] = spO2_min; doc["data"]["oxygen"]["spO2_diff"] = spO2_diff; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } void colmiBattery(uint16_t batteryLevel, bool isCharging) { JsonDocument doc; doc["data"]["battery"]["level"] = batteryLevel; doc["data"]["battery"]["charging"] = isCharging; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } // executed when the ring is charging or idle void colmiIdle(void) { JsonDocument doc; doc["state"] = "idle"; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } // process incoming 16-byte packets from the ring void processColmiPacket(unsigned char *pData, size_t length) { if (pData == nullptr) { return; } uint8_t packetHead = pData[0]; uint8_t packetType = pData[1]; switch (packetHead) { case 0xa1: switch (packetType) { case 0x01: { int16_t spO2 = (pData[2] << 8) | pData[3]; int16_t spO2_max = pData[5]; int16_t spO2_min = pData[7]; int16_t spO2_diff = pData[9]; #ifdef DEBUG Serial.printf("SP02 -> %d | MAX: %d | MIN: %d | DIFF: %d\n", spO2, spO2_max, spO2_min, spO2_diff); #endif colmiOxygen(spO2, spO2_max, spO2_min, spO2_diff); } break; case 0x02: { int16_t ppg = (pData[2] << 8) | pData[3]; int16_t ppg_max = (pData[4] << 8) | pData[5]; int16_t ppg_min = (pData[6] << 8) | pData[7]; int16_t ppg_diff = (pData[8] << 8) | pData[9]; #ifdef DEBUG Serial.printf("PPG -> %d | MAX: %d | MIN: %d | DIFF: %d\n", ppg, ppg_max, ppg_min, ppg_diff); #endif colmiHeartrate(ppg, ppg_max, ppg_min, ppg_diff); } break; case 0x03: { int16_t accX = pData[6] & 0x8 ? ((pData[6] << 4) | (pData[7] & 0xF)) - (1 << 11) : ((pData[6] << 4) | (pData[7] & 0xF)); int16_t accY = pData[2] & 0x8 ? ((pData[2] << 4) | (pData[3] & 0xF)) - (1 << 11) : ((pData[2] << 4) | (pData[3] & 0xF)); int16_t accZ = pData[4] & 0x8 ? ((pData[4] << 4) | (pData[5] & 0xF)) - (1 << 11) : ((pData[4] << 4) | (pData[5] & 0xF)); #ifdef DEBUG Serial.printf("ACC -> X: %d | Y: %d | Z: %d\n", accX, accY, accZ); #endif colmiAccelerometer(accX, accY, accZ); } break; case 0xFF: { switch(packetType) { case 0xFF: #ifdef DEBUG Serial.println("Ring -> Idle"); #endif colmiIdle(); break; default: #ifdef DEBUG Serial.printf("unknown packet: \n"); printHex(pData, 16); #endif break; } } break; default: #ifdef DEBUG Serial.printf("unknown packet: \n"); printHex(pData, 16); #endif break; } break; case 0x73: { switch(packetType) { case 0x0C: { int16_t batteryLevel = pData[2]; bool isCharging = (pData[3] == 0x01); #ifdef DEBUG Serial.printf("Battery -> Level: %d | Status: %s\n", batteryLevel, isCharging ? "charging" : "discharging"); #endif colmiBattery(batteryLevel, isCharging); } break; default: #ifdef DEBUG Serial.printf("unknown packet: \n"); printHex(pData, 16); #endif break; } } break; default: #ifdef DEBUG Serial.printf("unknown packet: \n"); printHex(pData, 16); #endif break; } } void onNimBLENotify(NimBLERemoteCharacteristic* pRemoteCharacteristic, unsigned char *pData, unsigned int length, bool isNotify) { #ifdef DEBUG Serial.printf("\nData received (length: %d).\n", length); #endif processColmiPacket(pData, length); } bool nimBLEWriteCharacteristic(NimBLERemoteService* pSvc, NimBLEUUID characteristic, unsigned char *pData, unsigned int pDataLength) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pSvc == nullptr || !pSvc->getClient()->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return false; } // compute packet checksum and adjust the payload uint8_t packetChecksum = colmiPacketChecksum(pData); pData[15] = packetChecksum; NimBLERemoteCharacteristic* pWriteChar = pSvc->getCharacteristic(characteristic); if (pWriteChar != nullptr && pWriteChar->canWrite()) { if(!pWriteChar->writeValue(pData, pDataLength)) { xSemaphoreGive(nimBLEClientSemaphore); return false; } } xSemaphoreGive(nimBLEClientSemaphore); return true; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client write characteristics.\n"); #endif return false; } bool nimBLEUnsubscribe(NimBLERemoteService *pSvc, NimBLEUUID characteristic) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pSvc == nullptr || !pSvc->getClient()->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return false; } NimBLERemoteCharacteristic *pChar = pSvc->getCharacteristic(characteristic); if (pChar != nullptr && pChar->canNotify()) { if(!pChar->unsubscribe(true)) { xSemaphoreGive(nimBLEClientSemaphore); return true; } } xSemaphoreGive(nimBLEClientSemaphore); return false; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client subscribe.\n"); #endif return false; } NimBLERemoteCharacteristic *nimBLESubscribe(NimBLERemoteService *pSvc, NimBLEUUID characteristic, nimBLENotify callback) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pSvc == nullptr || !pSvc->getClient()->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } NimBLERemoteCharacteristic *pChar = pSvc->getCharacteristic(characteristic); if (pChar != nullptr && pChar->canNotify()) { if(!pChar->subscribe(true, callback)) { xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } } xSemaphoreGive(nimBLEClientSemaphore); return pChar; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client subscribe.\n"); #endif return nullptr; } int nimBLEQueryRssi(NimBLEClient *pClient) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pClient == nullptr || !pClient->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return 0; } int rssi = pClient->getRssi(); xSemaphoreGive(nimBLEClientSemaphore); return rssi; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client service retrieval.\n"); #endif return 0; } NimBLERemoteService *nimBLEGetService(NimBLEClient *pClient, NimBLEUUID service) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pClient == nullptr || !pClient->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } NimBLERemoteService *pSvc = pClient->getService(service); if (pSvc == nullptr) { xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } xSemaphoreGive(nimBLEClientSemaphore); return pSvc; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client service retrieval.\n"); #endif return nullptr; } bool nimBLEDisconnectClient(NimBLEClient *pClient) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { if (pClient == nullptr || !pClient->isConnected()) { xSemaphoreGive(nimBLEClientSemaphore); return true; } // disconnect the client if (pClient->isConnected()) { if(!pClient->disconnect()) { return false; } } xSemaphoreGive(nimBLEClientSemaphore); return true; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client disposal.\n"); #endif return false; } NimBLEClient *nimBLEConnect(NimBLEAddress nimBLEAddress, unsigned int timeout) { if (xSemaphoreTake(nimBLEClientSemaphore, pdMS_TO_TICKS(nimBLELockTimeout)) == pdPASS) { // reuse or create a client NimBLEClient* pClient = nullptr; pClient = NimBLEDevice::getDisconnectedClient(); if (pClient == nullptr) { pClient = NimBLEDevice::createClient(); if(pClient == nullptr) { #ifdef DEBUG Serial.println("Failed to create client."); #endif xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } } // the second parameter makes it such that the callbacks are automatically removed on client disconnect pClient->setClientCallbacks(new ClientCallbacks(), true); // 150ms interval, 0 latency, 1000ms (100 * 10ms) supervision timeout // must be greater than (1 + latency) * maxInterval pClient->setConnectionParams(24, 40, 0, nimBLESupervisionTimeout); // set the timeout and try a synchronous connection. pClient->setConnectTimeout(timeout); if (!pClient->connect(nimBLEAddress, false, false)) { xSemaphoreGive(nimBLEClientSemaphore); return nullptr; } xSemaphoreGive(nimBLEClientSemaphore); return pClient; } #ifdef DEBUG Serial.printf("\nFailed to acquire lock on client creation.\n"); #endif return nullptr; } String getMqttClientId(void) { // store the MQTT id in a global varible to prevent redundant CPU usage if(mqttClientIdCache.length() != 0) { return mqttClientIdCache; } mqttClientIdCache = String(HOSTNAME()); JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif return mqttClientIdCache; } if(!configuration["mqttClientId"].is()) { return mqttClientIdCache; } mqttClientIdCache = configuration["mqttClientId"].as(); return mqttClientIdCache; } String getMqttTopic(void) { if(mqttTopicCache.length() != 0) { return mqttTopicCache; } mqttTopicCache = String(HOSTNAME()); JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif return mqttTopicCache; } if(!configuration["mqttTopic"].is()) { return mqttTopicCache; } mqttTopicCache = configuration["mqttTopic"].as(); return mqttTopicCache; } JsonDocument mqttColmiConnect(JsonDocument &doc) { // connect, get service, subscribe, request #ifdef DEBUG Serial.println("Disconnecting: "); #endif // disconnect if(!nimBLEDisconnectClient(pClient)) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Connecting: "); #endif pClient = nimBLEConnect(COLMI_ADDRESS, nimBLEConnectTimeout); if (pClient == nullptr) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Getting service: "); #endif pSvc = nimBLEGetService(pClient, COLMI_SERVICE); if (pSvc == nullptr) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Subscribing: "); #endif pChar = nimBLESubscribe(pSvc, COLMI_NOTIFY, onNimBLENotify); if (pChar == nullptr) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Start RAW data: "); #endif if (!nimBLEWriteCharacteristic(pSvc, COLMI_WRITE, COLMI_START_RAW_DATA, 16)) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif doc["success"] = true; return doc; } JsonDocument mqttColmiReboot(JsonDocument &doc) { // connect, get service, reboot if(!nimBLEConnected) { #ifdef DEBUG Serial.println("Disconnecting: "); #endif // disconnect if(!nimBLEDisconnectClient(pClient)) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Connecting: "); #endif pClient = nimBLEConnect(COLMI_ADDRESS, nimBLEConnectTimeout); if (pClient == nullptr) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif #ifdef DEBUG Serial.print("Getting service: "); #endif pSvc = nimBLEGetService(pClient, COLMI_SERVICE); if (pSvc == nullptr) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif } #ifdef DEBUG Serial.print("Rebooting: "); #endif if (!nimBLEWriteCharacteristic(pSvc, COLMI_WRITE, COLMI_REBOOT, 16)) { #ifdef DEBUG Serial.println("BAD"); #endif doc["success"] = false; return doc; } #ifdef DEBUG Serial.println("OK"); #endif doc["success"] = true; return doc; } JsonDocument mqttColmiQueryRssi(JsonDocument &doc) { #ifdef DEBUG Serial.println("Retrieving RSSI."); #endif doc["signal"] = nimBLEQueryRssi(pClient); return doc; } void mqttCallback(char *topic, byte *payload, unsigned int length) { String msgTopic = String(topic); // do not listen on topics not subscribed to or on empty topics if(msgTopic.length() == 0) { return; } // only listen on the topic that has been subscribed to if(!msgTopic.equals(mqttTopicCache)) { return; } // Read the MQTT payload as a JSON document. JsonDocument doc; //char payloadBuffer[MQTT_PAYLOAD_MAX_LENGTH]; //size_t payloadLength = strlcpy(payloadBuffer, (const char *)payload, length + 1); #ifdef DEBUG Serial.println("Deserializing message..."); #endif DeserializationError error = deserializeJson(doc, (const char *)payload, length); if (error) { #ifdef DEBUG Serial.printf("Reading the payload as JSON failed with %s\n", error.f_str()); #endif return; } #ifdef DEBUG //Serial.printf("Message received on topic %s with payload %s...\n", topic, payload); #endif // Do not process messages without a command key. if (!doc.containsKey("command")) { #ifdef DEBUG Serial.printf("No command provided.\n"); #endif return; } String command = String(doc["command"].as()); // normalize command command.trim(); command.toUpperCase(); if(command.length() == 0) { #ifdef DEBUG Serial.println("Empty command provided."); #endif return; } JsonDocument msg; msg["execute"] = doc; switch(mqttColmiCommandHash(command.c_str())) { case mqttColmiCommandHash("CONNECT"): msg = mqttColmiConnect(doc); break; case mqttColmiCommandHash("REBOOT"): msg = mqttColmiReboot(doc); break; case mqttColmiCommandHash("RSSI"): msg = mqttColmiQueryRssi(doc); break; default: #ifdef DEBUG Serial.println("Unknown command."); #endif msg["success"] = false; break; } mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(msg), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(msg, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); } bool mqttConnect(void) { if(rebootPending) { #ifdef DEBUG Serial.println("Reboot pending, cancelling MQTT connection."); #endif return false; } // retrieve the configuration so we do not end up overwriting the old client settings JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { // if the configuration could not be retrieved then just reboot #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif return false; } #ifdef DEBUG Serial.printf("MQTT broker parameters, client ID %s, host %s, port %d, username %s, password %s and topic %s.\n", getMqttClientId().c_str(), configuration["mqttHost"].as(), configuration["mqttPort"].as(), configuration["mqttUsername"].as(), configuration["mqttPassword"].as(), configuration["mqttTopic"].as() ); #endif mqttClient.setServer(configuration["mqttHost"].as(), configuration["mqttPort"].as()); if (mqttClient.connect( getMqttClientId().c_str(), configuration["mqttUsername"].as(), configuration["mqttPassword"].as())) { mqttClient.setCallback(mqttCallback); #ifdef DEBUG Serial.print("Subscribing to MQTT topic " + getMqttTopic() + ": "); #endif if (!mqttClient.subscribe(getMqttTopic().c_str())) { #ifdef DEBUG Serial.println("fail"); #endif return false; } #ifdef DEBUG Serial.println("success"); #endif // Announce success at connecting and subscribing to MQTT broker. JsonDocument doc; doc["client"] = getMqttClientId().c_str(); doc["topic"] = getMqttTopic().c_str(); doc["command"] = "hello"; mqttClient.beginPublish((getMqttTopic() + "/" + MQTT_TOPIC_OUTBOX).c_str(), measureJson(doc), false); BufferingPrint bufferedClient(mqttClient, 64); serializeJson(doc, bufferedClient); bufferedClient.flush(); mqttClient.endPublish(); return true; } #ifdef DEBUG Serial.println("Connection to MQTT broker failed with MQTT client state: " + String(mqttClient.state())); #endif return false; } /////////////////////////////////////////////////////////////////////////// // Arduino loop // /////////////////////////////////////////////////////////////////////////// void mqttTickCallback(void) { if(!networkConnected || rebootPending) { return; } // Process MQTT client loop. int mqttState = mqttClient.state(); switch(mqttState) { case MQTT_CONNECTED: mqttClient.loop(); return; case MQTT_CONNECT_BAD_PROTOCOL: #ifdef DEBUG Serial.println("MQTT bad protocol."); #endif break; case MQTT_CONNECTION_LOST: #ifdef DEBUG Serial.println("MQTT connection lost."); #endif break; case MQTT_CONNECTION_TIMEOUT: #ifdef DEBUG Serial.println("MQTT client timeout."); #endif break; case MQTT_DISCONNECTED: #ifdef DEBUG Serial.println("MQTT client disconected."); #endif break; case MQTT_CONNECT_BAD_CLIENT_ID: #ifdef DEBUG Serial.println("MQTT bad client ID."); #endif break; case MQTT_CONNECT_UNAVAILABLE: #ifdef DEBUG Serial.println("MQTT broker was unable to accept the connection."); #endif break; case MQTT_CONNECT_BAD_CREDENTIALS: #ifdef DEBUG Serial.println("MQTT broker rejected the credentials."); #endif break; case MQTT_CONNECT_UNAUTHORIZED: #ifdef DEBUG Serial.println("MQTT client was not authorized by broker."); #endif break; default: #ifdef DEBUG Serial.printf("Unknown MQTT client state %d\n", mqttState); #endif break; } if (!mqttConnect()) { #ifdef DEBUG Serial.printf("Unable to connect to MQTT\n"); #endif } } /////////////////////////////////////////////////////////////////////////// // OTA updates // /////////////////////////////////////////////////////////////////////////// void arduinoOtaTickCallback(void) { ArduinoOTA.handle(); if(!networkConnected || rebootPending) { return; } if(!otaStarted) { #ifdef DEBUG Serial.printf("Starting OTA...\n"); #endif ArduinoOTA.begin(); otaStarted = true; } } /////////////////////////////////////////////////////////////////////////// // system-wide reboot // /////////////////////////////////////////////////////////////////////////// void rebootTickCallback(void) { // if not reboot hasbeen scheduled then just return if(!rebootPending) { return; } #ifdef DEBUG Serial.printf("Stopping filesystem...\n"); #endif #ifdef DEBUG LittleFS.end(); #endif #ifdef DEBUG Serial.printf("Rebooting...\n"); #endif ESP.restart(); } /////////////////////////////////////////////////////////////////////////// // HTTP route handling // /////////////////////////////////////////////////////////////////////////// void handleRootHttpPost(void) { String password; for(int i = 0; i < server.args(); ++i) { if(server.argName(i) == "password") { password = server.arg(i); continue; } } if(!password.equals(PREBOOT_MASTER_PASSWORD)) { server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } #ifdef DEBUG Serial.println("Authentication succeeded, setting cookie and redirecting."); #endif // clear old authentication cookie if(authenticationCookie != NULL) { free(authenticationCookie); authenticationCookie = NULL; } authenticationCookie = randomStringHex(8); char* buff = (char*) malloc(PREBOOT_COOKIE_MAX_LENGTH * sizeof(char)); snprintf(buff, PREBOOT_COOKIE_MAX_LENGTH, "%s=%s; Max-Age=600; SameSite=Strict", PREBOOT_COOKIE_NAME, authenticationCookie); #ifdef DEBUG Serial.printf("Preboot cookie set to: %s\n", buff); #endif server.sendHeader("Set-Cookie", buff); server.sendHeader("Location", "/setup"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); free(buff); } void handleSetupHttpPost(void) { String espName, ssid, password, mqttHost, mqttTopic, mqttUsername, mqttPassword, mqttClientId; int mqttPort; for(int i = 0; i < server.args(); ++i) { if(server.argName(i) == "name") { espName = server.arg(i); continue; } if(server.argName(i) == "ssid") { ssid = server.arg(i); continue; } if(server.argName(i) == "password") { password = server.arg(i); continue; } if(server.argName(i) == "mqttClientId") { mqttClientId = server.arg(i); continue; } if(server.argName(i) == "mqttHost") { mqttHost = server.arg(i); continue; } if(server.argName(i) == "mqttPort") { String mqttUserPort = server.arg(i); mqttPort = mqttUserPort.toInt(); continue; } if(server.argName(i) == "mqttTopic") { mqttTopic = server.arg(i); continue; } if(server.argName(i) == "mqttUsername") { mqttUsername = server.arg(i); continue; } if(server.argName(i) == "mqttPassword") { mqttPassword = server.arg(i); continue; } } if(!espName || espName.length() == 0 || !ssid || ssid.length() == 0 || !password || password.length() == 0) { server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } #ifdef DEBUG Serial.printf("SSID %s and password %s received from web application.\n", ssid, password); #endif JsonDocument configuration; // Miscellaneous configuration["boot"] = BOOT_MODE_CLIENT; // WiFi settings configuration["name"] = espName; configuration["ssid"] = ssid; configuration["password"] = password; // MQTT settings configuration["mqttClientId"] = mqttClientId; configuration["mqttHost"] = mqttHost; configuration["mqttPort"] = mqttPort; configuration["mqttTopic"] = mqttTopic; configuration["mqttUsername"] = mqttUsername; configuration["mqttPassword"] = mqttPassword; if(!setConfiguration(CONFIGURATION_FILE_NAME, configuration)) { #ifdef DEBUG Serial.printf("Failed to write configuration.\n"); #endif server.sendHeader("Location", "/setup"); server.sendHeader("Cache-Control", "no-cache"); server.send(307); return; } server.send(200, "text/plain", "Parameters applied. Scheduling reboot..."); #ifdef DEBUG Serial.printf("Configuration applied...\n"); #endif rebootPending = true; } void handleRootHttpGet(void) { // send login form #ifdef DEBUG Serial.printf("Sending authentication webpage.\n"); #endif String processTemplate = String(HTML_AUTH_TEMPLATE); server.send(200, "text/html", processTemplate); } void handleSetupHttpGet(void) { JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif server.sendHeader("Location", "/setup"); server.sendHeader("Cache-Control", "no-cache"); server.send(307); } String espName = HOSTNAME(); if(configuration["name"].is()) { espName = configuration["name"].as(); } String mqttHost = MQTT_HOST; if(configuration["mqttHost"].is()) { mqttHost = configuration["mqttHost"].as(); } int mqttPort = MQTT_PORT; if(configuration["mqttPort"].is()) { mqttPort = configuration["mqttPort"].as(); } String mqttTopic = getMqttTopic(); if(configuration["mqttTopic"].is()) { mqttTopic = configuration["mqttTopic"].as(); } String mqttClientId = getMqttClientId(); if(configuration["mqttClientId"].is()) { mqttClientId = configuration["mqttClientId"].as(); } String mqttUsername = MQTT_USERNAME; if(configuration["mqttUsername"].is()) { mqttUsername = configuration["mqttUsername"].as(); } String mqttPassword = MQTT_PASSWORD; if(configuration["mqttPassword"].is()) { mqttPassword = configuration["mqttPassword"].as(); } // send default boot webpage #ifdef DEBUG Serial.printf("Sending configuration form webpage.\n"); #endif String processTemplate = FPSTR(HTML_SETUP_TEMPLATE); processTemplate.replace("%AP%", generateTemporarySSID()); processTemplate.replace("%MAC%", getHardwareAddress(':')); processTemplate.replace("%NAME%", espName); processTemplate.replace("%MQTT_CLIENT_ID%", mqttClientId); processTemplate.replace("%MQTT_HOST%", mqttHost); processTemplate.replace("%MQTT_PORT%", String(mqttPort)); processTemplate.replace("%MQTT_TOPIC%", mqttTopic); processTemplate.replace("%MQTT_USERNAME%", mqttUsername); processTemplate.replace("%MQTT_PASSWORD%", mqttPassword); server.send(200, "text/html", processTemplate); } void handleRootHttpRequest(void) { switch(server.method()) { case HTTP_GET: handleRootHttpGet(); break; case HTTP_POST: handleRootHttpPost(); break; } } void handleRootCssRequest(void) { if(server.method() != HTTP_GET) { handleHttpNotFound(); return; } #ifdef DEBUG Serial.println("Sending stylesheet..."); #endif String rootCss = FPSTR(GENERIC_CSS_TEMPLATE); server.send(200, "text/css", rootCss); } void handleSetupHttpRequest(void) { #ifdef DEBUG Serial.println("HTTP setup request received."); #endif if(!server.hasHeader("Cookie")) { #ifdef DEBUG Serial.println("No cookie header found."); #endif server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } String cookie = server.header("Cookie"); if(authenticationCookie == NULL || cookie.indexOf(authenticationCookie) == -1) { #ifdef DEBUG Serial.println("Authentication failed."); #endif server.sendHeader("Location", "/"); server.sendHeader("Cache-Control", "no-cache"); server.send(302); return; } switch(server.method()) { case HTTP_GET: #ifdef DEBUG Serial.printf("HTTP GET request received for setup.\n"); #endif handleSetupHttpGet(); break; case HTTP_POST: #ifdef DEBUG Serial.printf("HTTP POST request received for setup.\n"); #endif handleSetupHttpPost(); break; } } void handleHttpNotFound(void) { server.sendHeader("Cache-Control", "no-cache"); server.send(404); } /////////////////////////////////////////////////////////////////////////// // set the current configuration // /////////////////////////////////////////////////////////////////////////// bool setConfiguration(const char* configurationFile, JsonDocument &configuration) { #if defined(ARDUINO_ARCH_ESP8266) File file = LittleFS.open(configurationFile, "w"); #elif defined(ARDUINO_ARCH_ESP32) File file = LittleFS.open(configurationFile, FILE_WRITE); #endif if(!file) { #ifdef DEBUG Serial.println("Failed to open file for writing."); #endif return false; } size_t bytesWritten = serializeJson(configuration, file); file.close(); #ifdef DEBUG Serial.printf("Written bytes %d vs. document bytes %d\n", bytesWritten, measureJson(configuration)); #endif return bytesWritten == measureJson(configuration); } /////////////////////////////////////////////////////////////////////////// // get the current configuration // /////////////////////////////////////////////////////////////////////////// int getConfiguration(const char* configurationFile, JsonDocument &configuration) { #if defined(ARDUINO_ARCH_ESP8266) File file = LittleFS.open(configurationFile, "r"); #elif defined(ARDUINO_ARCH_ESP32) File file = LittleFS.open(configurationFile); #endif if (!file) { #ifdef DEBUG Serial.println("Failed to open file for reading."); #endif return false; } DeserializationError error = deserializeJson(configuration, file); file.close(); if(error) { #ifdef DEBUG Serial.printf("Deserialization failed with error %s\n", error.c_str()); #endif return -1; } return measureJson(configuration); } /////////////////////////////////////////////////////////////////////////// // generate random string // /////////////////////////////////////////////////////////////////////////// char* randomStringHex(int length) { const char alphabet[] = "0123456789abcdef"; char* payload = (char*) malloc(length * sizeof(char)); int i; for (i=0; i= WIFI_SERVER_TIMEOUT) { #ifdef DEBUG Serial.println("Server timeout, rebooting...\n"); #endif // retrieve the configuration so we do not end up overwriting the old client settings JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { // if the configuration could not be retrieved then just reboot #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif rebootPending = true; return; } // reboot to client mode in order to retry the connection configuration["boot"] = BOOT_MODE_CLIENT; if(!setConfiguration(CONFIGURATION_FILE_NAME, configuration)) { #ifdef DEBUG Serial.printf("Failed to write configuration.\n"); #endif } rebootPending = true; return; } #ifdef DEBUG /* if(callbackTickTime % 1000 == 0 ) { Serial.printf("Time till reboot %.0fs\n", (float)(WIFI_SERVER_TIMEOUT - callbackTickTime)/1000.0); } */ #endif // create the temporary SSID for the initial configuration String temporarySSID = generateTemporarySSID(); if(WiFi.softAPSSID().equals(temporarySSID)) { // run WiFi server loops dnsServer.processNextRequest(); server.handleClient(); #if defined(ESP8266) MDNS.update(); #endif if(blinkDigitsDahTick.state() == STOPPED) { temporarySSIDLength = temporarySSID.length(); temporarySSIDNumbers = (int *) malloc(temporarySSIDLength * sizeof(int)); for(int i = 0; i < temporarySSIDLength; ++i) { temporarySSIDNumbers[i] = temporarySSID[i] - '0'; } temporarySSIDIndex = 0; blinkDigitsDahTick.start(); } return; } #ifdef DEBUG Serial.println("Starting HTTP server for Wifi server."); #endif // handle HTTP REST requests server.on("/", handleRootHttpRequest); server.on("/setup", handleSetupHttpRequest); server.on("/style.css", handleRootCssRequest); // captive portal proprietary junk redirected to webserver root // connectivitycheck.gstatic.com/generate_204 // www.googe.com/gen_204 server.on("/generate_204", handleRootHttpRequest); server.on("/gen_204", handleRootHttpRequest); server.on("/fwlink", handleRootHttpRequest); server.onNotFound(handleHttpNotFound); #ifdef DEBUG Serial.println("Ensure HTTP headers are collected by the HTTP server."); #endif #if defined(ARDUINO_ARCH_ESP8266) server.collectHeaders("Cookie"); #elif defined(ARDUINO_ARCH_ESP32) const char* collectHeaders[] = { "Cookie" }; size_t headerkeyssize = sizeof(collectHeaders) / sizeof(char *); server.collectHeaders(collectHeaders, headerkeyssize); #endif // the soft AP (or WiFi) must be started before the HTTP server or it will result in a crash on ESP32 #ifdef DEBUG Serial.println("Starting temporary AP."); #endif JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif } WiFi.softAPConfig(softAPAddress, softAPAddress, softAPNetmask); WiFi.softAP(temporarySSID, String(), 1, false, 1); dnsServer.setErrorReplyCode(DNSReplyCode::NoError); dnsServer.start(53, "*", softAPAddress); #ifdef DEBUG Serial.println("Starting HTTP server."); #endif if (!discoveryStarted) { if(!MDNS.begin(temporarySSID)) { #ifdef DEBUG Serial.println("Error setting up MDNS responder."); #endif return; } discoveryStarted = true; } server.begin(); } /////////////////////////////////////////////////////////////////////////// // connect to WiFi // /////////////////////////////////////////////////////////////////////////// // schedule a reboot to server mode for some connection failures void clientWifiCallback(WiFiEvent_t event, WiFiEventInfo_t info) { if (event != ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { return; } uint8_t reason = info.wifi_sta_disconnected.reason; #ifdef DEBUG Serial.printf("\n[WiFi] Disconnected. Reason Code: %u - ", reason); #endif JsonDocument configuration; switch (reason) { case 23: #ifdef DEBUG Serial.println("CIPHER_SUITE_REJECTED: WPA2/WPA3 mismatch"); #endif case 202: #ifdef DEBUG Serial.println("AUTH_FAIL: Wrong password"); #endif #ifdef DEBUG Serial.printf("Reboot to server mode.\n"); #endif if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif return; } // reboot to client mode in order to retry the connection configuration["boot"] = BOOT_MODE_SERVER; if(!setConfiguration(CONFIGURATION_FILE_NAME, configuration)) { #ifdef DEBUG Serial.printf("Failed to write configuration.\n"); #endif } rebootPending = true; return; case 2: #ifdef DEBUG Serial.println("AUTH_EXPIRE: Password incorrect or signal timeout"); #endif break; case 3: #ifdef DEBUG Serial.println("AUTH_LEAVE: Manual disconnect"); #endif break; case 15: #ifdef DEBUG Serial.println("4WAY_HANDSHAKE_TIMEOUT: Check power supply/current"); #endif break; case 201: #ifdef DEBUG Serial.println("NO_AP_FOUND: SSID incorrect or out of range"); #endif break; case 205: #ifdef DEBUG Serial.println("BEACON_TIMEOUT: AP signal lost (distance/interference)"); #endif break; case 1: #ifdef DEBUG Serial.println("Unspecified failure"); #endif break; default: #ifdef DEBUG Serial.println("Internal ESP32 WiFi Error"); #endif break; } } void clientWifiTickCallback(void) { if(rebootPending || otaInProgress) { return; } unsigned long callbackCount = clientWifiTick.counter(); #ifdef DEBUG //Serial.printf("Client tick %lu\n", callbackCount); #endif if(callbackCount == 1) { #ifdef DEBUG Serial.printf("Rescheduling client WiFi...\n"); #endif clientWifiTick.interval(WIFI_RETRY_TIMEOUT); clientWifiTick.resume(); return; } // if WiFi is already connected or a reboot is pending just bail out wl_status_t wifiStatus = WiFi.status(); if(wifiStatus == WL_CONNECTED) { if (!discoveryStarted) { JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif rebootPending = true; return; } if(!MDNS.begin(configuration["name"].as())) { #ifdef DEBUG Serial.println("Error setting up MDNS responder."); #endif return; } discoveryStarted = true; } #ifdef DEBUG Serial.println("-- MARK --"); #endif clientConnectionTries = 0; networkConnected = true; #if defined(ESP8266) MDNS.update(); #endif return; } #ifdef DEBUG Serial.printf("Client WiFi not connected: %s\n", wl_status_to_string(wifiStatus)); #endif networkConnected = false; JsonDocument configuration; if(getConfiguration(CONFIGURATION_FILE_NAME, configuration) == -1) { #ifdef DEBUG Serial.println("Unable to retrieve configuration."); #endif return; } // even if configuration exists, check whether STA and password exist if(!configuration["ssid"] || configuration["ssid"].as().length() == 0 || !configuration["password"] || configuration["password"].as().length() == 0) { // reboot to client mode in order to retry the connection configuration["boot"] = BOOT_MODE_SERVER; if(!setConfiguration(CONFIGURATION_FILE_NAME, configuration)) { #ifdef DEBUG Serial.printf("Failed to write configuration.\n"); #endif } rebootPending = true; return; } // set SSID and password String ssid = configuration["ssid"].as(); String password = configuration["password"].as(); // too many retries so reboot to soft AP if(++clientConnectionTries > WIFI_CONNECT_TRIES) { configuration["boot"] = BOOT_MODE_SERVER; if(!setConfiguration(CONFIGURATION_FILE_NAME, configuration)) { #ifdef DEBUG Serial.printf("Failed to write configuration.\n"); #endif } #ifdef DEBUG Serial.printf("Restarting in 1 second...\n"); #endif rebootPending = true; return; } #ifdef DEBUG Serial.printf("Attempting to establish WiFi STA connecton [%d/%d]\n", (WIFI_CONNECT_TRIES - clientConnectionTries) + 1, WIFI_CONNECT_TRIES); #endif #if defined(ARDUINO_ARCH_ESP8266) WiFi.hostname(configuration["name"].as()); #elif defined(ARDUINO_ARCH_ESP32) WiFi.setHostname(configuration["name"].as()); #endif #ifdef DEBUG Serial.printf("Trying connection to %s with %s...\n", ssid, password); #endif //WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE); WiFi.begin(ssid, password); } /////////////////////////////////////////////////////////////////////////// // blink the temporary ssid // /////////////////////////////////////////////////////////////////////////// void blinkDigitsDahTickCallback(void) { // wait for the dits to complete if(blinkDigitsDitTick.state() != STOPPED) { return; } if(temporarySSIDIndex >= temporarySSIDLength) { blinkDigitsDahTick.stop(); blinkDigitsDitTick.stop(); blinkDigitsBlinkTick.stop(); free(temporarySSIDNumbers); #ifdef DEBUG Serial.println(); Serial.println("Dah-dit blink sequence completed."); #endif return; } #ifdef DEBUG Serial.printf("Starting to blink %d times: ", temporarySSIDNumbers[temporarySSIDIndex]); #endif pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, LOW); blinkDigitsDitTick.start(); } void blinkDigitsDitTickCallback(void) { #ifdef DEBUG Serial.printf("Dit: %d/%d\n", blinkDigitsDitTick.counter(), temporarySSIDNumbers[temporarySSIDIndex]); #endif if(blinkDigitsDitTick.counter() > temporarySSIDNumbers[temporarySSIDIndex]) { blinkDigitsDitTick.stop(); ++temporarySSIDIndex; #ifdef DEBUG Serial.println("Dits completed..."); #endif return; } blinkDigitsDitTick.pause(); blinkDigitsBlinkTick.start(); } void blinkDigitsBlinkTickCallback(void) { if(blinkDigitsBlinkTick.counter() > 2) { blinkDigitsBlinkTick.stop(); blinkDigitsDitTick.resume(); return; } blinkLedState = !blinkLedState; digitalWrite(LED_BUILTIN, blinkLedState); }