diff --git a/.github/workflows/build_firmware.yml b/.github/workflows/build_firmware.yml index b834435..5bbe7f8 100644 --- a/.github/workflows/build_firmware.yml +++ b/.github/workflows/build_firmware.yml @@ -38,7 +38,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.13" - name: Install PlatformIO run: pip install platformio - name: Update PlatformIO diff --git a/data/www/espa.js b/data/www/espa.js index bd27515..ce7a18c 100644 --- a/data/www/espa.js +++ b/data/www/espa.js @@ -96,6 +96,7 @@ function fetchStatus() { updateStatusElement('status_serial', value_json.status.serial); updateStatusElement('status_siInitialised', value_json.status.siInitialised); updateStatusElement('status_mqtt', value_json.status.mqtt); + updateEspaControlStatus(value_json.status.espaControl); updateStatusElement('espa_model', value_json.eSpa.model); updateStatusElement('espa_build', value_json.eSpa.update.installed_version); }) @@ -111,6 +112,7 @@ function fetchStatus() { handleStatusError('status_serial'); handleStatusError('status_siInitialised'); handleStatusError('status_mqtt'); + handleStatusError('status_espaControl'); handleStatusError('espa_model'); handleStatusError('espa_build'); }); @@ -126,6 +128,37 @@ function updateStatusElement(elementId, value) { } } +function updateEspaControlStatus(status) { + const element = document.getElementById('status_espaControl'); + if (!element) return; + + element.classList.remove('text-bg-success', 'text-bg-danger', 'text-bg-warning', 'text-bg-secondary'); + + switch(status) { + case 'connected': + element.classList.add('text-bg-success'); + element.textContent = 'Connected'; + break; + case 'disconnected': + element.classList.add('text-bg-warning'); + element.textContent = 'Disconnected'; + break; + case 'not_paired': + element.classList.add('text-bg-secondary'); + element.textContent = 'Not Paired'; + break; + case 'disabled': + element.classList.add('text-bg-secondary'); + element.textContent = 'Disabled'; + break; + case 'unavailable': + default: + element.classList.add('text-bg-secondary'); + element.textContent = 'Unavailable'; + break; + } +} + function handleStatusError(elementId) { const element = document.getElementById(elementId); element.classList.remove('text-bg-warning'); diff --git a/data/www/index.htm b/data/www/index.htm index 5c7c8ea..69f490b 100644 --- a/data/www/index.htm +++ b/data/www/index.htm @@ -36,6 +36,7 @@ Wi-Fi Manager Firmware Updater Send Current Time to Spa + Connect to eSpa Control Reboot eSpa @@ -125,6 +126,10 @@

Status

MQTT status: Loading... + + eSpa Control connection: + Loading... + eSpa Model: Loading... diff --git a/data/www/pairing.htm b/data/www/pairing.htm new file mode 100644 index 0000000..40f0b9b --- /dev/null +++ b/data/www/pairing.htm @@ -0,0 +1,397 @@ + + + + + + + + + eSpa Control Pairing + + + + + +
+
+
+ +
+
+ +
+

Connect to eSpa Control

+

Cloud monitoring and control for your spa 🏊‍♂️

+ +
+ + +
+
+
+
+
+ 1 + Setup +
+
+ 2 + Pair +
+
+ 3 + Done +
+
+
+
+ + +
+
+
+

🚀 Get Started

+

+ Pair your device with eSpa Control to monitor and control your spa from anywhere 🌍 +

+ +
+
+
+ + + + +
+
+ 🔑 Your Device ID + Loading... +
+
+
+ + + +
+ +
+
+
+
+ + + + + + +
+
+
+ + + + + + + + diff --git a/data/www/styles.css b/data/www/styles.css index 239a853..218a8fd 100644 --- a/data/www/styles.css +++ b/data/www/styles.css @@ -24,11 +24,8 @@ table { } */ h1, h2, h3 { - text-align: left; font-weight: 300; padding-top: 20px; - display: flex; - align-items: center; } .footer { diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..6a50860 --- /dev/null +++ b/dev.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# Quick helper script for common development tasks + +set -e + +DEVICE_IP="${ESPA_IP:-10.0.0.198}" + +# Function to find the ESP32 serial port +find_esp_port() { + # Look for usbmodem (most common for ESP32-S3) + local port=$(ls /dev/cu.usbmodem* 2>/dev/null | head -1) + if [ -z "$port" ]; then + # Fall back to any USB serial device + port=$(ls /dev/cu.usbserial* 2>/dev/null | head -1) + fi + echo "$port" +} + +# Function to clean serial port +clean_serial() { + # Kill any process using USB serial ports + lsof -t /dev/cu.usb* 2>/dev/null | xargs kill -9 2>/dev/null || true + sleep 0.5 +} + +# Function to ensure clean build (fixes LDF mode issues) +ensure_clean_build() { + # Check if we need a clean build (first time or after errors) + if [ ! -f ".pio/.dev-build-ok" ]; then + echo "First dev build or previous errors - doing complete clean..." + rm -rf .pio + mkdir -p .pio + touch .pio/.dev-build-ok + fi +} + +case "$1" in + build) + echo "Building dev environment..." + ensure_clean_build + pio run -e espa-v1 + ;; + + clean) + echo "Cleaning build completely..." + rm -rf .pio + echo "Clean complete. Next build will reinstall all dependencies." + ;; + + upload) + echo "Building and uploading firmware..." + ensure_clean_build + clean_serial + PORT=$(find_esp_port) + if [ -z "$PORT" ]; then + echo "ERROR: No ESP32 device found. Please connect your device." + exit 1 + fi + echo "Using port: $PORT" + pio run -e espa-v1 --target upload --upload-port "$PORT" + ;; + + monitor) + echo "Opening serial monitor..." + PORT=$(find_esp_port) + if [ -z "$PORT" ]; then + echo "ERROR: No ESP32 device found. Please connect your device." + exit 1 + fi + echo "Using port: $PORT" + pio device monitor --port "$PORT" + ;; + + uploadfs) + echo "Building and uploading filesystem..." + ensure_clean_build + clean_serial + PORT=$(find_esp_port) + if [ -z "$PORT" ]; then + echo "ERROR: No ESP32 device found. Please connect your device." + exit 1 + fi + echo "Using port: $PORT" + pio run -e espa-v1 --target uploadfs --upload-port "$PORT" + ;; + + full) + echo "Build, upload firmware+filesystem, and monitor..." + ensure_clean_build + clean_serial + PORT=$(find_esp_port) + if [ -z "$PORT" ]; then + echo "ERROR: No ESP32 device found. Please connect your device." + exit 1 + fi + echo "Using port: $PORT" + echo "" + echo "Step 1: Uploading firmware..." + pio run -e espa-v1 --target upload --upload-port "$PORT" + echo "" + echo "Step 2: Uploading filesystem..." + pio run -e espa-v1 --target uploadfs --upload-port "$PORT" + echo "" + echo "Upload complete! Starting serial monitor (Press Ctrl+C to exit)..." + echo "" + sleep 1 + pio device monitor --port "$PORT" + ;; + + test) + echo "Testing script functionality..." + echo "" + echo "✓ Script is executable" + PORT=$(find_esp_port) + if [ -z "$PORT" ]; then + echo "✗ No ESP32 device found" + exit 1 + else + echo "✓ ESP32 found at: $PORT" + fi + if [ -f ".pio/.dev-build-ok" ]; then + echo "✓ Build marker exists (incremental builds enabled)" + else + echo "⚠ No build marker (next build will be clean)" + fi + echo "" + echo "Script is ready to use!" + ;; + + unpair) + echo "Unpairing device at $DEVICE_IP..." + curl -X POST "http://$DEVICE_IP/api/espa-control/unpair" + echo "" + ;; + + info) + echo "Device info at $DEVICE_IP:" + echo "" + echo "Device ID:" + curl -s "http://$DEVICE_IP/api/espa-control/device-id" | jq + echo "" + echo "Config:" + curl -s "http://$DEVICE_IP/api/espa-control/config" | jq + ;; + + *) + echo "eSpa Development Helper" + echo "" + echo "Usage: $0 {command}" + echo "" + echo "Commands:" + echo " build - Build dev environment" + echo " clean - Clean build cache (fixes WiFi.h errors)" + echo " upload - Build and upload firmware" + echo " uploadfs - Build and upload filesystem" + echo " monitor - Open serial monitor (Ctrl+C to exit)" + echo " full - Build, upload firmware+filesystem, and monitor" + echo " test - Test script functionality" + echo " unpair - Clear pairing token" + echo " info - Show device info (ID, config)" + echo "" + echo "Environment:" + echo " ESPA_IP - Device IP (default: espa.local)" + echo " Example: ESPA_IP=192.168.1.50 $0 info" + exit 1 + ;; +esac diff --git a/lib/Config/Config.cpp b/lib/Config/Config.cpp index f28764a..db79232 100644 --- a/lib/Config/Config.cpp +++ b/lib/Config/Config.cpp @@ -25,6 +25,7 @@ bool Config::readConfig() { SpaPollFrequency.setValue(preferences.getInt("spaPollFreq", 60)); SoftAPAlwaysOn.setValue(preferences.getBool("SoftAPAlwaysOn", true)); SoftAPPassword.setValue(preferences.getString("SoftAPPassword", "eSPA-Password")); + EspaToken.setValue(preferences.getString("EspaToken", "")); preferences.end(); return true; @@ -46,6 +47,7 @@ void Config::writeConfig() { preferences.putInt("spaPollFreq", SpaPollFrequency.getValue()); preferences.putBool("SoftAPAlwaysOn", SoftAPAlwaysOn.getValue()); preferences.putString("SoftAPPassword", SoftAPPassword.getValue()); + preferences.putString("EspaToken", EspaToken.getValue()); preferences.end(); } else { debugE("Failed to open Preferences for writing"); diff --git a/lib/Config/Config.h b/lib/Config/Config.h index 17f917b..efbf0fb 100644 --- a/lib/Config/Config.h +++ b/lib/Config/Config.h @@ -72,6 +72,7 @@ class ControllerConfig { Setting SpaPollFrequency = Setting("SpaPollFrequency", 60, 10, 300); Setting SoftAPAlwaysOn = Setting("SoftAPAlwaysOn", true); Setting SoftAPPassword = Setting("SoftAPPassword", "eSPA-Password"); + Setting EspaToken = Setting("EspaToken", ""); }; class Config : public ControllerConfig { diff --git a/lib/MultiBlinker/MultiBlinker.cpp b/lib/MultiBlinker/MultiBlinker.cpp index e2fe3bf..9cbf377 100644 --- a/lib/MultiBlinker/MultiBlinker.cpp +++ b/lib/MultiBlinker/MultiBlinker.cpp @@ -51,7 +51,7 @@ void MultiBlinker::start() { return; } running = true; - xTaskCreate(runTask, "MultiBlinkerTask", 2048, this, 1, &taskHandle); + xTaskCreate(runTask, "MultiBlinkerTask", 4096, this, 1, &taskHandle); } void MultiBlinker::stop() { diff --git a/lib/MultiBlinker/MultiBlinker.h b/lib/MultiBlinker/MultiBlinker.h index 5908d08..97bd2d0 100644 --- a/lib/MultiBlinker/MultiBlinker.h +++ b/lib/MultiBlinker/MultiBlinker.h @@ -11,7 +11,7 @@ extern RemoteDebug Debug; const int PCB_LED1 = 14; const int PCB_LED2 = 41; const int PCB_LED3 = 42; - const int PCB_LED4 = 43; + const int PCB_LED4 = -1; // GPIO 43 conflicts with USB on ESP32-S3, disabled #elif defined(ESPA_V2) const int PCB_LED1 = 18; const int PCB_LED2 = 21; diff --git a/lib/SpaUtils/SpaUtils.cpp b/lib/SpaUtils/SpaUtils.cpp index a265883..9cdaa83 100644 --- a/lib/SpaUtils/SpaUtils.cpp +++ b/lib/SpaUtils/SpaUtils.cpp @@ -126,7 +126,11 @@ int getPumpSpeedMin(String pumpInstallState) { return min; } -bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String &output, bool prettyJson) { +#ifdef ENABLE_ESPA_CONTROL +#include "EspaControl.h" +#endif + +bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String &output, bool prettyJson, EspaControl *espaControl) { JsonDocument json; json["temperatures"]["setPoint"] = si.getSTMP() / 10.0; @@ -152,6 +156,25 @@ bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String json["status"]["serial"] = si.getSerialNo1() + "-" + si.getSerialNo2(); json["status"]["siInitialised"] = si.isInitialised()?"true":"false"; json["status"]["mqtt"] = mqttClient.connected()?"connected":"disconnected"; + +#ifdef ENABLE_ESPA_CONTROL + // Add eSpa Control connection status + if (espaControl) { + if (espaControl->isPaired()) { + if (espaControl->isConnected()) { + json["status"]["espaControl"] = "connected"; + } else { + json["status"]["espaControl"] = "disconnected"; + } + } else { + json["status"]["espaControl"] = "not_paired"; + } + } else { + json["status"]["espaControl"] = "unavailable"; + } +#else + json["status"]["espaControl"] = "disabled"; +#endif json["eSpa"]["model"] = xstr(PIOENV); json["eSpa"]["update"]["installed_version"] = xstr(BUILD_INFO); diff --git a/lib/SpaUtils/SpaUtils.h b/lib/SpaUtils/SpaUtils.h index 24fcac9..95f6544 100644 --- a/lib/SpaUtils/SpaUtils.h +++ b/lib/SpaUtils/SpaUtils.h @@ -27,6 +27,9 @@ String getPumpPossibleStates(String pumpState); int getPumpSpeedMax(String pumpState); int getPumpSpeedMin(String pumpState); -bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String &output, bool prettyJson=false); +// Forward declaration (always present to allow function signature to compile) +class EspaControl; + +bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String &output, bool prettyJson=false, EspaControl *espaControl=nullptr); #endif // SPAUTILS_H diff --git a/lib/WebUI/WebUI.cpp b/lib/WebUI/WebUI.cpp index fb60ed0..cefb611 100644 --- a/lib/WebUI/WebUI.cpp +++ b/lib/WebUI/WebUI.cpp @@ -1,4 +1,8 @@ #include "WebUI.h" +#ifdef ENABLE_ESPA_CONTROL +#include +#include +#endif WebUI::WebUI(SpaInterface *spa, Config *config, MQTTClientWrapper *mqttClient) { _spa = spa; @@ -127,7 +131,11 @@ void WebUI::begin() { debugD("uri: %s", request->url().c_str()); String json; AsyncWebServerResponse *response; +#ifdef ENABLE_ESPA_CONTROL + if (generateStatusJson(*_spa, *_mqttClient, json, true, _espaControl)) { +#else if (generateStatusJson(*_spa, *_mqttClient, json, true)) { +#endif response = request->beginResponse(200, "application/json", json); } else { response = request->beginResponse(200, "text/plain", "Error generating json"); @@ -170,6 +178,152 @@ void WebUI::begin() { request->send(response); }); +#ifdef ENABLE_ESPA_CONTROL + // eSpa Control Pairing API Endpoints + + // Get eSpa Control configuration (server URL) + server.on("/api/espa-control/config", HTTP_GET, [this](AsyncWebServerRequest *request) { + debugD("uri: %s", request->url().c_str()); + bool isPaired = _espaControl ? _espaControl->isPaired() : false; + String json = "{\"serverUrl\":\"" + String(ESPA_CONTROL_SERVER_URL) + \ + "\",\"isPaired\":" + (isPaired ? "true" : "false") + "}"; + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + }); + + // Get device ID + server.on("/api/espa-control/device-id", HTTP_GET, [this](AsyncWebServerRequest *request) { + debugD("uri: %s", request->url().c_str()); + String deviceId = _espaControl ? _espaControl->getDeviceId() : "unknown"; + String json = "{\"deviceId\":\"" + deviceId + "\"}"; + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + }); + + // Handle pairing request + server.on("/api/espa-control/pair", HTTP_POST, [this](AsyncWebServerRequest *request){}, NULL, + [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + debugD("uri: %s", request->url().c_str()); + + if (!_espaControl) { + AsyncWebServerResponse *response = request->beginResponse(400, "application/json", + "{\"success\":false,\"message\":\"eSpa Control not initialized\"}"); + response->addHeader("Connection", "close"); + request->send(response); + return; + } + + // Parse JSON body + StaticJsonDocument<256> doc; + DeserializationError error = deserializeJson(doc, data, len); + + if (error) { + AsyncWebServerResponse *response = request->beginResponse(400, "application/json", + "{\"success\":false,\"message\":\"Invalid JSON\"}"); + response->addHeader("Connection", "close"); + request->send(response); + return; + } + + String deviceId = doc["deviceId"].as(); + String pairingCode = doc["pairingCode"].as(); + + if (pairingCode.length() != 6) { + AsyncWebServerResponse *response = request->beginResponse(400, "application/json", + "{\"success\":false,\"message\":\"Invalid pairing code\"}"); + response->addHeader("Connection", "close"); + request->send(response); + return; + } + + // Use EspaControl's pairing method + bool success = _espaControl->submitPairingCode(pairingCode); + + if (success) { + debugI("Pairing code submitted successfully"); + String json = "{\"success\":true,\"message\":\"Pairing code submitted\"}"; + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + } else { + debugE("Failed to submit pairing code"); + String json = "{\"success\":false,\"message\":\"Failed to submit pairing code\"}"; + AsyncWebServerResponse *response = request->beginResponse(400, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + } + }); + + // Get pairing status + server.on("/api/espa-control/pairing-status", HTTP_GET, [this](AsyncWebServerRequest *request) { + debugD("uri: %s", request->url().c_str()); + + if (!_espaControl) { + AsyncWebServerResponse *response = request->beginResponse(400, "application/json", + "{\"status\":\"NONE\",\"message\":\"eSpa Control not initialized\"}"); + response->addHeader("Connection", "close"); + request->send(response); + return; + } + + // Get pairing state from EspaControl + PairingState state = _espaControl->getPairingState(); + String statusStr; + + switch(state) { + case NOT_PAIRED: + statusStr = "NOT_PAIRED"; + break; + case CODE_SUBMITTED: + statusStr = "CODE_SUBMITTED"; + break; + case POLLING: + statusStr = "POLLING"; + break; + case PAIRED: + statusStr = "PAIRED"; + break; + case PAIRING_ERROR: + statusStr = "ERROR"; + break; + default: + statusStr = "UNKNOWN"; + break; + } + + String json = "{\"status\":\"" + statusStr + "\",\"isPaired\":" + + (_espaControl->isPaired() ? "true" : "false") + "}"; + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + }); + + // Unpair device - clear authentication token + server.on("/api/espa-control/unpair", HTTP_POST, [this](AsyncWebServerRequest *request) { + debugD("uri: %s", request->url().c_str()); + + if (_espaControl) { + _espaControl->unpair(); + debugI("Device unpaired via web UI"); + } + + // Also clear config token for backwards compatibility + _config->EspaToken.setValue(""); + _config->writeConfig(); + + // Clear pairing state + _pendingPairingDeviceId = ""; + _pairingStatus = ""; + + String json = "{\"success\":true,\"message\":\"Device unpaired successfully\"}"; + AsyncWebServerResponse *response = request->beginResponse(200, "application/json", json); + response->addHeader("Connection", "close"); + request->send(response); + }); +#endif + // As a fallback we try to load from /www any requested URL server.serveStatic("/", SPIFFS, "/www/"); diff --git a/lib/WebUI/WebUI.h b/lib/WebUI/WebUI.h index 7c5178e..699f2e7 100644 --- a/lib/WebUI/WebUI.h +++ b/lib/WebUI/WebUI.h @@ -11,6 +11,10 @@ #include "MQTTClientWrapper.h" #include "ESPAsyncWebServer.h" +#ifdef ENABLE_ESPA_CONTROL +#include "EspaControl.h" +#endif + extern RemoteDebug Debug; class WebUI { @@ -30,6 +34,14 @@ class WebUI { void begin(); bool initialised = false; + #ifdef ENABLE_ESPA_CONTROL + /// @brief Set the EspaControl instance for pairing support + /// @param control Pointer to EspaControl instance + void setEspaControl(EspaControl *control) { + _espaControl = control; + } + #endif + private: AsyncWebServer server{80}; SpaInterface *_spa; @@ -38,6 +50,14 @@ class WebUI { void (*_wifiManagerCallback)() = nullptr; void (*_setSpaCallback)(const String, const String) = nullptr; + #ifdef ENABLE_ESPA_CONTROL + EspaControl *_espaControl = nullptr; + + // Pairing state + String _pendingPairingDeviceId; + String _pairingStatus; // "PENDING", "APPROVED", "REJECTED", "" + #endif + const char* getError(); // hard-coded FOTA page in case file system gets wiped diff --git a/lib/espaControl/EspaControl.cpp b/lib/espaControl/EspaControl.cpp new file mode 100644 index 0000000..5875c9b --- /dev/null +++ b/lib/espaControl/EspaControl.cpp @@ -0,0 +1,1016 @@ +/* + * EspaControl.cpp - Implementation of ESPA Control Library + * + * This file contains the core implementation of the library that enables + * ESP32/ESP8266 devices to communicate with the ESPA Control Service. + * + * Key Implementation Details: + * + * MEMORY MANAGEMENT: + * - WebSocket instance created on heap in begin() + * - JsonDocument uses stack allocation (capacity: 1024 bytes) + * - String operations use Arduino String (dynamic allocation) + * - No manual memory management needed (RAII pattern) + * + * ERROR HANDLING STRATEGY: + * - Every public method wrapped in try-catch + * - User callbacks isolated with try-catch + * - Errors logged but never crash device + * - Failed operations trigger exponential backoff + * - Library continues functioning after any error + * + * TIMING & PERFORMANCE: + * - Non-blocking: all operations return immediately + * - Ping interval: 30 seconds + * - Reconnection: exponential backoff 1s -> 60s + * - Loop processing: typically < 1ms + * - Message handling: < 10ms + * + * WEBSOCKET PROTOCOL: + * All messages are JSON with a "type" field: + * + * Outbound: + * {"type": "state", "deviceId": "...", "state": {...}} + * {"type": "commandAck", "deviceId": "...", "success": true} + * {"type": "ping", "deviceId": "...", "timestamp": 12345} + * + * Inbound: + * {"type": "command", "command": {...}} + * {"type": "stateRequest"} + * {"type": "ping"} + * + * THREAD SAFETY: + * - Not thread-safe + * - All methods must be called from main loop + * - AsyncWebSocket handles async events internally + * + * Copyright (c) 2025 ESPA Control Contributors + * Licensed under MIT License + */ + +#include "EspaControl.h" +#include + +// Conditional WiFi include for MAC address reading +#include + +/** + * WebSocket connection URL will be constructed from ESPA_CONTROL_SERVER_URL + * converting http:// to ws:// and https:// to wss:// + * Default: wss://control.espa.diy/ws/device/{deviceId} + */ + +/** + * Constructor - Initialize all member variables to safe defaults + * + * Sets up: + * - Empty device ID (generated in begin()) + * - Server URL from build flags + * - Disabled debug mode + * - Disconnected state + * - Zero timing counters + * - Base reconnection delay + */ +EspaControl::EspaControl() + : _serverUrl(ESPA_CONTROL_SERVER_URL), + _debugEnabled(false), + _connected(false), + _lastPingTime(0), + _lastReconnectAttempt(0), + _reconnectAttempts(0), + _currentReconnectDelay(BASE_RECONNECT_DELAY), + _lastErrorTime(0), + _consecutiveErrors(0), + _setPropertyCallback(nullptr), + _connectionCallback(nullptr), + pairingState(NOT_PAIRED), + _config(nullptr), + lastPollTime(0), + pollInterval(5000), + pollAttempts(0), + serverPort(80), + _lastLoggedDelay(0), + _hasLoggedDisconnect(false) { +} + +EspaControl::~EspaControl() { + _ws.close(); +} + +/** + * begin() - Initialize library and connect to cloud service + * + * Initialization Sequence: + * 1. Check WiFi connection + * 2. Generate unique device ID from WiFi MAC + * 3. Configure WebSocket client event handlers + * 4. Construct WebSocket URL: wss://control.espa.diy/ws/device/{deviceId} + * 5. Initiate connection to cloud service + * + * WebSocket URL Construction: + * - http://... -> ws://... + * - https://... -> wss://... + * - Path: /ws/device/aabbccddeeff + * + * Error Cases: + * - WiFi not connected + * - MAC address unavailable + * - Connection failure (will retry in loop()) + * + * @return true if initialization successful, false on error + */ +bool EspaControl::begin(Config* config) { + // Store config pointer + _config = config; + + // Check WiFi connection + if (WiFi.status() != WL_CONNECTED) { + logError("WiFi not connected - cannot start ESPA Control"); + return false; + } + + // Generate device ID from MAC address (12 hex chars: aabbccddeeff) + _deviceId = generateDeviceId(); + deviceId = _deviceId; // Also store in public deviceId member + + if (_deviceId.length() == 0) { + logError("Failed to generate device ID"); + return false; + } + + try { + // Parse server URL to extract host and port + parseServerUrl(); + + // Setup WebSocket event handlers using lambdas to capture 'this' + _ws.onMessage([this](WebsocketsMessage message) { + this->onMessage(message); + }); + + _ws.onEvent([this](WebsocketsEvent event, String data) { + this->onEvent(event, data); + }); + + log("ESPA Control initialized for device: " + _deviceId); + log("Server URL: " + _serverUrl); + log("Server Host: " + serverHost + ":" + String(serverPort)); + + // Try to load existing pairing token from NVS + if (loadPairingToken()) { + log("Loaded existing pairing token from storage"); + pairingState = PAIRED; + // Attempt connection to authenticated WebSocket endpoint + connectWebSocket(); + } else { + log("No pairing token found - device needs pairing"); + pairingState = NOT_PAIRED; + // Don't connect WebSocket until paired + } + + return true; + } catch (...) { + logError("Exception during initialization"); + return false; + } +} + +void EspaControl::onSetProperty(SetPropertyCallback callback) { + _setPropertyCallback = callback; + log("SetProperty callback registered"); +} + +void EspaControl::onConnected(ConnectionCallback callback) { + _connectionCallback = callback; + log("Connection callback registered"); +} + +/** + * loop() - Main processing function called from Arduino loop() + * + * **MUST BE CALLED REGULARLY!** + * + * Processing Tasks: + * 1. WebSocket client cleanup (removes dead connections) + * 3. Reconnection attempts (with exponential backoff when disconnected) + * + * Timing Behavior: + * - Normally: < 1ms execution time (just checks timers) + * - During ping: ~5ms (JSON serialization + send) + * - During reconnect: ~10ms (connection setup) + * - Non-blocking: never uses delay() + * + * Exponential Backoff Algorithm: + * When disconnected: + * - Wait _currentReconnectDelay before attempting reconnect + * - After each failed attempt, double the delay + * - Minimum: 1 second + * - Maximum: 60 seconds + * - Reset to 1 second on successful connection + * + * Example Timeline: + * - T+0s: Disconnect detected + * - T+1s: First reconnect attempt (fails) + * - T+3s: Second attempt (1+2, fails) + * - T+7s: Third attempt (3+4, fails) + * - T+15s: Fourth attempt (7+8, succeeds) + * - Connected, backoff reset to 1s + * + * Error Handling: + * - Entire function wrapped in try-catch + * - Any exception logged and ignored + * - Library continues functioning + */ +void EspaControl::loop() { + try { + uint32_t now = millis(); + + // Check pairing status if in POLLING state + if (pairingState == POLLING) { + checkPairingStatus(); + } + + // Only manage WebSocket if paired + if (pairingState == PAIRED) { + // Poll for WebSocket events (messages, connection state changes) + if (_ws.available()) { + _ws.poll(); + } + + // Attempt reconnection if disconnected (with exponential backoff) + if (!_connected && shouldAttemptReconnect()) { + try { + connectWebSocket(); + _lastReconnectAttempt = now; + } catch (...) { + handleError("Reconnection attempt failed"); + increaseReconnectDelay(); + } + } + } + } catch (...) { + // Catch-all to ensure loop never crashes the device + handleError("Critical error in loop"); + } +} + +bool EspaControl::publishState(const String& stateJson) { + if (!_connected) { + log("Cannot publish state: not connected"); + return false; + } + + if (_authToken.length() == 0) { + log("Cannot publish state: not authenticated"); + return false; + } + + try { + // Parse the state JSON to validate it + JsonDocument stateDoc; + DeserializationError err = deserializeJson(stateDoc, stateJson); + if (err) { + String errorMsg = "Invalid JSON state: " + String(err.c_str()); + handleError(errorMsg.c_str()); + return false; + } + + // Create message envelope + JsonDocument message; + message["type"] = "state"; + message["deviceId"] = _deviceId; + message["timestamp"] = millis(); + message["state"] = stateDoc; + + // Serialize to string + String output; + serializeJson(message, output); + + // Send via WebSocket + if (_ws.available()) { + _ws.send(output); + log("State published"); + _consecutiveErrors = 0; // Reset error count on success + return true; + } + + log("Cannot publish state: WebSocket not available"); + return false; + } catch (...) { + handleError("Failed to publish state"); + return false; + } +} + +bool EspaControl::isConnected() const { + return _connected; +} + +void EspaControl::setDebug(bool enable) { + _debugEnabled = enable; + if (enable) { + log("Debug logging enabled"); + } +} + +void EspaControl::setAuthToken(const String& token) { + _authToken = token; + log("Authentication token set"); + + // If we're already connected but not authenticated, reconnect + if (_connected && token.length() > 0) { + log("Reconnecting with new auth token"); + _ws.close(); + _connected = false; + } +} + +bool EspaControl::hasAuthToken() const { + return _authToken.length() > 0; +} + +void EspaControl::clearAuthToken() { + log("Clearing authentication token"); + _authToken = ""; + + if (_connected) { + _ws.close(); + _connected = false; + } +} + +/** + * onMessage() - Handle incoming WebSocket messages + * + * Called by ArduinoWebsockets when text message received. + * Parses JSON and routes to appropriate handler. + */ +void EspaControl::onMessage(WebsocketsMessage message) { + try { + if (message.isText()) { + String data = message.data(); + log("Received message: " + data); + handleWebSocketMessage(data); + } + } catch (...) { + handleError("Failed to process WebSocket message"); + } +} + +/** + * onEvent() - Handle WebSocket connection events + * + * Event Types: + * - ConnectionOpened: Successfully connected to cloud service + * - ConnectionClosed: Disconnected (will attempt reconnection) + * - GotPing: Ping received from server + * - GotPong: Pong response to our ping + */ +void EspaControl::onEvent(WebsocketsEvent event, String data) { + try { + switch (event) { + case WebsocketsEvent::ConnectionOpened: + log("Connected to ESPA Control Service"); + _connected = true; + _lastPingTime = millis(); + resetReconnectDelay(); + + // Send authentication message if we have a token + if (_authToken.length() > 0) { + JsonDocument authMsg; + authMsg["type"] = "auth"; + authMsg["deviceId"] = _deviceId; + authMsg["token"] = _authToken; + + String output; + serializeJson(authMsg, output); + _ws.send(output); + log("Authentication sent"); + + // Trigger connection callback to publish initial state (only when authenticated) + if (_connectionCallback) { + log("Calling connection callback"); + _connectionCallback(); + } + } else { + log("Warning: No auth token - device may not be paired"); + } + break; + + case WebsocketsEvent::ConnectionClosed: + // Only log disconnect once, not on every failed reconnect + if (!_hasLoggedDisconnect) { + log("Disconnected from ESPA Control Service"); + _hasLoggedDisconnect = true; + } + _connected = false; + increaseReconnectDelay(); + break; + + case WebsocketsEvent::GotPing: + log("Ping received from server"); + _ws.pong(); + break; + + case WebsocketsEvent::GotPong: + log("Pong received from server"); + _consecutiveErrors = 0; + break; + } + } catch (...) { + handleError("Critical error in WebSocket event handler"); + } +} + +/** + * handleWebSocketMessage() - Parse and route incoming WebSocket messages + * + * Message Processing: + * 1. Parse JSON from message string + * 2. Extract "type" field + * 3. Route to appropriate handler + * + * Supported Message Types: + * - "command": Remote command to execute + * - "stateRequest": Request for current device state + * - "ping": Keep-alive ping from server + */ +void EspaControl::handleWebSocketMessage(const String& data) { + try { + Serial.println("[EspaControl] WebSocket message received:"); + Serial.println(data); + + // Parse JSON + JsonDocument doc; + DeserializationError error = deserializeJson(doc, data); + + if (error) { + String errorMsg = "JSON parse error: " + String(error.c_str()); + Serial.println("[EspaControl] " + errorMsg); + handleError(errorMsg.c_str()); + return; + } + + Serial.println("[EspaControl] JSON parsed successfully"); + + // Check message type + const char* msgType = doc["type"]; + if (!msgType) { + log("Message missing type field"); + return; + } + + try { + if (strcmp(msgType, "command") == 0) { + handleCommand(doc); + } else if (strcmp(msgType, "stateRequest") == 0) { + handleStateRequest(); + } else if (strcmp(msgType, "ping") == 0) { + // Send pong + JsonDocument pong; + pong["type"] = "pong"; + pong["deviceId"] = _deviceId; + String output; + serializeJson(pong, output); + _ws.send(output); + } else if (strcmp(msgType, "connected") == 0) { + log("Connection acknowledged by server"); + } else if (strcmp(msgType, "error") == 0) { + const char* errorMsg = doc["message"]; + log("Server error: " + String(errorMsg ? errorMsg : "unknown")); + } else { + log("Unknown message type: " + String(msgType)); + } + } catch (...) { + handleError("Exception handling message"); + } + } catch (...) { + handleError("Exception in WebSocket message handler"); + } +} + +void EspaControl::handleCommand(const JsonDocument& doc) { + try { + if (!_setPropertyCallback) { + log("Command received but no setProperty callback registered"); + return; + } + + // Extract the properties object + if (!doc["properties"].is()) { + log("Command message missing properties field"); + return; + } + + JsonVariantConst properties = doc["properties"]; + + log("Processing command with " + String(properties.size()) + " properties"); + + // Process each property + bool allSuccess = true; + for (JsonPairConst kv : properties.as()) { + const char* property = kv.key().c_str(); + const char* value = kv.value().as(); + + if (!property || !value) { + log("Skipping invalid property/value pair"); + allSuccess = false; + continue; + } + + log("Setting property: " + String(property) + " = " + String(value)); + Serial.print("[EspaControl] Setting property: "); + Serial.print(property); + Serial.print(" = "); + Serial.println(value); + + // Call the setProperty callback (protect from callback errors) + try { + bool success = _setPropertyCallback(String(property), String(value)); + Serial.print("[EspaControl] Property set result: "); + Serial.println(success ? "SUCCESS" : "FAILED"); + + if (!success) { + log("SetProperty callback returned false for: " + String(property)); + allSuccess = false; + } + } catch (...) { + String errorMsg = "Exception in setProperty callback for: " + String(property); + handleError(errorMsg.c_str()); + allSuccess = false; + } + } + + // Send acknowledgment (protected) + try { + JsonDocument ack; + ack["type"] = "commandAck"; + ack["deviceId"] = _deviceId; + ack["success"] = allSuccess; + ack["timestamp"] = millis(); + + String output; + serializeJson(ack, output); + + if (_ws.available()) { + _ws.send(output); + } + + log("Command " + String(allSuccess ? "succeeded" : "failed")); + } catch (...) { + handleError("Failed to send command acknowledgment"); + } + + // State will be published via normal mqttPublishStatus flow + } catch (...) { + handleError("Critical error in command handler"); + } +} + +void EspaControl::handleStateRequest() { + try { + log("State request received - state should be published via publishState()"); + // State is published externally via publishState() method + // This ensures we don't duplicate the JSON generation logic + } catch (...) { + handleError("Failed to handle state request"); + } +} + +void EspaControl::connectWebSocket() { + // Don't try if WiFi is down + if (WiFi.status() != WL_CONNECTED) { + return; // Silently skip - WiFi down is normal during boot/reconnect + } + + // Don't connect if not paired + if (pairingState != PAIRED) { + return; // Silently skip - not paired is expected state + } + + // Don't try to connect if already connected or connecting + if (_connected || _ws.available()) { + return; // Already connected or connection in progress + } + + // Clean up any existing connection before attempting new one + // This prevents socket errors from stale connections + try { + _ws.close(); + } catch (...) { + // Ignore errors during cleanup + } + + // Construct WebSocket URL + // Convert http:// to ws:// and https:// to wss:// + String wsUrl = _serverUrl; + if (wsUrl.startsWith("https://")) { + wsUrl.replace("https://", "wss://"); + } else if (wsUrl.startsWith("http://")) { + wsUrl.replace("http://", "ws://"); + } + + // Use authenticated endpoint with token + wsUrl += "/ws/device/" + _deviceId + "?token=" + _authToken; + + // Only log first attempt to reduce spam (delay increases are logged separately) + bool shouldLog = (_reconnectAttempts == 0); + if (shouldLog) { + log("Attempting WebSocket connection..."); + } + + // Attempt connection + if (_ws.connect(wsUrl)) { + // Connection initiated - onEvent will handle success/failure + } else { + // Connection failed - only log on first attempt + if (shouldLog) { + handleError("WebSocket connection failed"); + } + } +} + +void EspaControl::sendPing() { + try { + if (_ws.available()) { + JsonDocument ping; + ping["type"] = "ping"; + ping["deviceId"] = _deviceId; + ping["timestamp"] = millis(); + + String output; + serializeJson(ping, output); + _ws.send(output); + + log("Ping sent"); + _consecutiveErrors = 0; // Reset on successful ping + } + } catch (...) { + handleError("Failed to send ping"); + } +} + +void EspaControl::log(const char* message) { + if (_debugEnabled) { + Serial.print("[EspaControl] "); + Serial.println(message); + } +} + +void EspaControl::log(const String& message) { + log(message.c_str()); +} + +String EspaControl::generateDeviceId() { + uint8_t mac[6]; + WiFi.macAddress(mac); + + char deviceId[18]; + snprintf(deviceId, sizeof(deviceId), "%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + return String(deviceId); +} + +// Error handling and resilience methods + +void EspaControl::handleError(const char* error) { + _consecutiveErrors++; + logError(error); + + // If too many consecutive errors, increase backoff significantly + if (_consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + _currentReconnectDelay = MAX_RECONNECT_DELAY; + logError("Too many consecutive errors, backing off"); + } +} + +void EspaControl::resetReconnectDelay() { + _reconnectAttempts = 0; + _currentReconnectDelay = BASE_RECONNECT_DELAY; + _consecutiveErrors = 0; + _lastLoggedDelay = 0; + _hasLoggedDisconnect = false; + log("Reconnect delay reset - connection stable"); +} + +void EspaControl::increaseReconnectDelay() { + _reconnectAttempts++; + + // Exponential backoff: double the delay each time, up to MAX_RECONNECT_DELAY + uint32_t oldDelay = _currentReconnectDelay; + _currentReconnectDelay = _currentReconnectDelay * 2; + if (_currentReconnectDelay > MAX_RECONNECT_DELAY) { + _currentReconnectDelay = MAX_RECONNECT_DELAY; + } + + // Only log when delay increases to a new level (not when already at max) + // This prevents spam when server is down for extended periods + if (oldDelay != _currentReconnectDelay && _currentReconnectDelay != _lastLoggedDelay) { + log("Reconnect delay increased to " + String(_currentReconnectDelay/1000) + "s"); + _lastLoggedDelay = _currentReconnectDelay; + } +} + +bool EspaControl::shouldAttemptReconnect() { + uint32_t now = millis(); + + // Check if enough time has passed since last reconnect attempt + if (now - _lastReconnectAttempt < _currentReconnectDelay) { + return false; + } + + return true; +} + +void EspaControl::logError(const char* error) { + uint32_t now = millis(); + + // Rate-limit error logging to avoid flooding serial output + if (now - _lastErrorTime > ERROR_COOLDOWN) { + Serial.print("[EspaControl ERROR] "); + Serial.println(error); + _lastErrorTime = now; + } +} + +// ==================== Pairing Flow Implementation ==================== + +bool EspaControl::submitPairingCode(const String& code) { + if (code.length() != 6) { + logError("Invalid pairing code: must be 6 digits"); + return false; + } + + // If already paired, clear existing pairing to allow re-pairing + if (pairingState == PAIRED) { + log("Clearing existing pairing to allow re-pair"); + clearPairingToken(); + } + + try { + HTTPClient http; + + // Construct pairing request URL + String url = String("http://") + serverHost + ":" + String(serverPort) + + String(ESPA_CONTROL_PAIRING_REQUEST_PATH); + + log("Submitting pairing code to: " + url); + + http.begin(url); + http.addHeader("Content-Type", "application/json"); + + // Build request body + JsonDocument requestDoc; + requestDoc["deviceId"] = deviceId; + requestDoc["pairingCode"] = code; + + String requestBody; + serializeJson(requestDoc, requestBody); + + log("Pairing request: " + requestBody); + + // Send POST request + int httpCode = http.POST(requestBody); + + if (httpCode == 200) { + String response = http.getString(); + log("Pairing code accepted: " + response); + + // Parse response + JsonDocument responseDoc; + DeserializationError error = deserializeJson(responseDoc, response); + + if (!error) { + // Check if immediately approved + if (responseDoc["approved"] == true) { + _authToken = responseDoc["token"].as(); + savePairingToken(_authToken); + pairingState = PAIRED; + log("Device paired immediately!"); + connectWebSocket(); + http.end(); + return true; + } + } + + // Not immediately approved - start polling + pairingState = POLLING; + pollInterval = 5000; // Start with 5s interval + pollAttempts = 0; + lastPollTime = millis(); + log("Pairing code submitted - waiting for approval"); + http.end(); + return true; + + } else { + String error = "Pairing request failed: HTTP " + String(httpCode); + if (httpCode > 0) { + error += " - " + http.getString(); + } + logError(error.c_str()); + pairingState = PAIRING_ERROR; + http.end(); + return false; + } + + } catch (...) { + logError("Exception during pairing code submission"); + pairingState = PAIRING_ERROR; + return false; + } +} + +void EspaControl::checkPairingStatus() { + if (pairingState != POLLING) { + return; + } + + uint32_t now = millis(); + + // Check if it's time to poll based on current interval + if (now - lastPollTime < pollInterval) { + return; + } + + try { + HTTPClient http; + + // Construct status check URL + String url = String("http://") + serverHost + ":" + String(serverPort) + + String(ESPA_CONTROL_PAIRING_STATUS_PATH) + deviceId; + + log("Checking pairing status: " + url); + + http.begin(url); + int httpCode = http.GET(); + + if (httpCode == 200) { + String response = http.getString(); + log("Pairing status response: " + response); + + JsonDocument responseDoc; + DeserializationError error = deserializeJson(responseDoc, response); + + if (!error) { + String status = responseDoc["status"].as(); + if (status == "APPROVED") { + _authToken = responseDoc["token"].as(); + savePairingToken(_authToken); + pairingState = PAIRED; + log("Device paired successfully!"); + connectWebSocket(); + http.end(); + return; + } + } + + // Not approved yet - continue polling with backoff + pollAttempts++; + lastPollTime = now; + + // Implement exponential backoff: 5s -> 10s -> 30s + if (pollAttempts >= 6 && pollInterval < 30000) { + pollInterval = 30000; // After 6 attempts (~30s), poll every 30s + log("Pairing poll interval increased to 30s"); + } else if (pollAttempts >= 2 && pollInterval < 10000) { + pollInterval = 10000; // After 2 attempts (~10s), poll every 10s + log("Pairing poll interval increased to 10s"); + } + + // Timeout after 5 minutes (300 seconds) + if (pollAttempts * pollInterval > 300000) { + logError("Pairing timeout - no approval after 5 minutes"); + pairingState = PAIRING_ERROR; + http.end(); + return; + } + + log("Pairing not approved yet, attempt " + String(pollAttempts)); + + } else { + String error = "Pairing status check failed: HTTP " + String(httpCode); + logError(error.c_str()); + // Don't give up on first failure, just log and try again + lastPollTime = now; + } + + http.end(); + + } catch (...) { + logError("Exception during pairing status check"); + lastPollTime = now; // Try again on next poll interval + } +} + +void EspaControl::savePairingToken(const String& token) { + if (!_config) { + logError("Config not initialized - cannot save token"); + return; + } + + try { + _config->EspaToken.setValue(token); + _config->writeConfig(); + log("Pairing token saved to config"); + } catch (...) { + logError("Failed to save pairing token to config"); + } +} + +bool EspaControl::loadPairingToken() { + if (!_config) { + logError("Config not initialized - cannot load token"); + return false; + } + + try { + _authToken = _config->EspaToken.getValue(); + + // Verify token exists + if (_authToken.length() > 0) { + log("Loaded auth token from config: " + _authToken.substring(0, 8) + "..."); + return true; + } + + log("No pairing token found in config"); + return false; + + } catch (...) { + logError("Exception loading pairing token from config"); + return false; + } +} + +void EspaControl::clearPairingToken() { + if (!_config) { + logError("Config not initialized - cannot clear token"); + return; + } + + try { + _config->EspaToken.setValue(""); + _config->writeConfig(); + + _authToken = ""; + pairingState = NOT_PAIRED; + + log("Pairing token cleared from config"); + + // Disconnect if currently connected + if (_connected) { + _ws.close(); + _connected = false; + } + + } catch (...) { + logError("Failed to clear pairing token from NVS"); + } +} + +void EspaControl::parseServerUrl() { + // Parse URL format: http://host:port or https://host:port + String url = _serverUrl; + + // Remove protocol + int protoEnd = url.indexOf("://"); + if (protoEnd != -1) { + url = url.substring(protoEnd + 3); + } + + // Find port separator + int portStart = url.indexOf(":"); + if (portStart != -1) { + serverHost = url.substring(0, portStart); + + // Extract port (find end of port number) + int portEnd = url.indexOf("/", portStart); + String portStr; + if (portEnd != -1) { + portStr = url.substring(portStart + 1, portEnd); + } else { + portStr = url.substring(portStart + 1); + } + + serverPort = portStr.toInt(); + if (serverPort == 0) { + serverPort = 80; // Default if parsing fails + } + } else { + // No port specified, use default + int pathStart = url.indexOf("/"); + if (pathStart != -1) { + serverHost = url.substring(0, pathStart); + } else { + serverHost = url; + } + + // Default port based on protocol + if (_serverUrl.startsWith("https://")) { + serverPort = 443; + } else { + serverPort = 80; + } + } + + log("Parsed server - Host: " + serverHost + ", Port: " + String(serverPort)); +} diff --git a/lib/espaControl/EspaControl.h b/lib/espaControl/EspaControl.h new file mode 100644 index 0000000..37c2952 --- /dev/null +++ b/lib/espaControl/EspaControl.h @@ -0,0 +1,873 @@ +#ifndef ESPA_CONTROL_H +#define ESPA_CONTROL_H + +/* + * EspaControl.h - Header file for ESPA Control Library + * + * This library provides ESP32/ESP8266 devices with remote control capabilities + * through the ESPA Control Service. It uses WebSocket for bidirectional + * communication and provides a callback-based interface. + * + * Key Design Principles: + * - Never crash the device (comprehensive error handling) + * - Graceful degradation (device works without service) + * - Automatic recovery (exponential backoff reconnection) + * - Zero-overhead when disabled (conditional compilation) + * - Minimal integration (just a few #ifdef blocks needed) + * + * Thread Safety: Not thread-safe. Call all methods from main loop only. + * Memory Usage: ~2KB RAM for library state + WebSocket buffers + * + * Copyright (c) 2025 ESPA Control Contributors + * Licensed under MIT License + */ + +// Conditional compilation support for testing +#include +#include +#include +#include +#include + +using namespace websockets; + +// Forward declaration +class Config; + +// ===================================================================== +// eSpa Control Service Configuration +// ===================================================================== +// Change these URLs to point to your local development server or production + +// Production server (default) +#ifndef ESPA_CONTROL_SERVER_URL +// #define ESPA_CONTROL_SERVER_URL "https://control.espa.diy" + #define ESPA_CONTROL_SERVER_URL "http://10.0.0.198:8080" +#endif + +// API endpoint paths - used for pairing and device communication +#define ESPA_CONTROL_WS_PATH "/ws/device/" // WebSocket path: /ws/device/{deviceId}?token={token} +#define ESPA_CONTROL_PAIRING_REQUEST_PATH "/api/device/pairing-request" +#define ESPA_CONTROL_PAIRING_STATUS_PATH "/api/device/pairing-status/" // Append deviceId + +// For local testing, define ESPA_CONTROL_SERVER_URL in your build flags: +// build_flags = -D ESPA_CONTROL_SERVER_URL=\"http://localhost:3000\" +// or +// build_flags = -D ESPA_CONTROL_SERVER_URL=\"http://192.168.1.100:3000\" + +/** + * EspaControl - Main library class for integrating ESP devices with the ESPA Control Servicerol Service + * + * ARCHITECTURE: + * This library acts as a bridge between your device firmware and the ESPA Control Service. + * It manages a WebSocket connection for bidirectional communication: + * + * Device Firmware <---> EspaControl <---> WebSocket <---> ESPA Control Service + * ^ | + * | | + * +-------- Callbacks for state/commands --------------------+ + * + * CALLBACKS: + * The library uses two callback functions that you provide: + * + * 1. StateCallback: Called when service requests current device state + * - Populate a JsonDocument with your device's current status + * - Return true if successful, false on error + * - Non-blocking: execute quickly and return + * + * 2. CommandCallback: Called when service sends a command to execute + * - Parse the JsonDocument to extract command parameters + * - Execute the command on your hardware + * - Return true if successful, false if command failed + * - Non-blocking: don't wait for long operations + * + * ERROR HANDLING: + * All library operations are wrapped in error handlers. Even if: + * - Your callbacks throw exceptions + * - Network fails completely + * - Service sends malformed data + * - Memory allocation fails + * ...your device will continue to operate normally. + * + * CONNECTION MANAGEMENT: + * The library automatically handles: + * - Initial connection establishment + * - Periodic ping/pong keep-alive (every 30 seconds) + * - Exponential backoff reconnection (1s -> 60s max) + * - Connection state tracking + * - Client cleanup + * + * USAGE EXAMPLE: + * EspaControl control; + * + * void setup() { + * // Initialize library (generates device ID from MAC) + * control.begin(server); + * + * // Register callbacks + * control.onSendState([](JsonDocument& doc) { + * doc["power"] = devicePower; + * doc["temp"] = currentTemp; + * return true; + * }); + * + * control.onReceiveCommand([](const JsonDocument& doc) { + * if (doc.containsKey("power")) { + * devicePower = doc["power"]; + * digitalWrite(RELAY_PIN, devicePower ? HIGH : LOW); + * } + * return true; + * }); + * } + * + * void loop() { + * control.loop(); // Must call regularly (handles all async operations) + * // ... rest of your code ... + * } + */ + +// Pairing state enumeration +enum PairingState { + NOT_PAIRED, // Device has no auth token + CODE_SUBMITTED, // Pairing code submitted, waiting for response + POLLING, // Polling for pairing approval + PAIRED, // Device is paired and has token + PAIRING_ERROR // Error during pairing process +}; + +class EspaControl { +public: + /** + * SetPropertyCallback - Callback function type for setting device properties + * + * This callback is invoked when the service sends commands to change device state. + * Commands arrive as simple property/value pairs that map directly to your + * existing setSpaProperty() or similar functions. + * + * COMMAND FORMAT: + * Commands are sent as JSON objects with property/value pairs: + * { + * "SetTemp": "38.5", + * "pump1_state": "ON", + * "pump2_speed": "2" + * } + * + * Your callback receives one property at a time and should: + * - Apply the property change to your device + * - Return true if successful, false if failed + * - Execute quickly (< 100ms recommended) + * + * Example: + * control.onSetProperty([](const String& property, const String& value) { + * setSpaProperty(property, value); // Call existing function + * return true; + * }); + * + * Common Properties: + * - SetTemp: Target temperature (e.g., "38.5") + * - pump1_state, pump2_state, etc: "ON" or "OFF" + * - pump1_speed, pump2_speed, etc: Speed level "0"-"5" + * - blower: "ON" or "OFF" + * - lights_state: "ON" or "OFF" + * - lights_brightness: Brightness level "0"-"10" + * - status_spaMode: Spa mode "NORM", "ECON", etc. + * + * Thread Safety: Called from main loop context only + * Error Handling: Wrapped in try-catch, exceptions won't crash device + * + * @param property Property name to set + * @param value Property value as string + * @return true if property was successfully set, false on error + */ + typedef std::function SetPropertyCallback; + + /** + * ConnectionCallback - Callback function type for connection events + * + * Called when WebSocket connection is established and ready for communication. + * Use this to immediately publish device state upon connection. + * + * Example: + * control.onConnected([]() { + * String json = generateStatusJson(); + * control.publishState(json); + * }); + */ + typedef std::function ConnectionCallback; + + + EspaControl(); + ~EspaControl(); + + /** + * submitPairingCode() - Submit a 6-digit pairing code for device pairing + * + * Call this when user provides a pairing code from the web UI. + * This initiates the pairing flow by POSTing to /api/device/pairing-request. + * + * @param code 6-digit pairing code from user + * @return true if submission successful, false on error + */ + bool submitPairingCode(const String& code); + + /** + * getPairingState() - Get current pairing state + * + * @return Current PairingState + */ + PairingState getPairingState() const { return pairingState; } + + /** + * isPaired() - Check if device is paired + * + * @return true if device has auth token and is paired + */ + bool isPaired() const { return pairingState == PAIRED && _authToken.length() > 0; } + + /** + * getDeviceId() - Get the device ID + * + * @return Device ID string (e.g., "ESPA-B43A45B946BC") + */ + String getDeviceId() const { return deviceId; } + + /** + * unpair() - Clear pairing and disconnect + * + * Removes stored auth token from NVS and resets pairing state. + * Disconnects from WebSocket if currently connected. + * Device returns to NOT_PAIRED state. + * + * Use this when user wants to unpair device or reset pairing. + */ + void unpair() { clearPairingToken(); } + + /** + * begin() - Initialize the ESPA Control library + * + * Call this once during setup() after WiFi is connected. + * + * @param config Pointer to Config instance for token persistence + * + * What this method does: + * 1. Generates unique device ID from WiFi MAC address + * 2. Configures WebSocket client to connect to cloud service + * 3. Registers WebSocket event handlers + * 4. Initiates connection to wss://control.espa.diy/ws/device/{deviceId} + * + * Device ID Generation: + * Device ID is automatically created from MAC address in format: aabbccddeeff + * This ensures each device has a unique, stable identifier. + * + * Example: + * EspaControl control; + * + * void setup() { + * WiFi.begin(SSID, PASSWORD); + * while (WiFi.status() != WL_CONNECTED) delay(100); + * + * if (control.begin()) { + * Serial.println("ESPA Control initialized"); + * } else { + * Serial.println("Initialization failed"); + * // Device continues to work, just no remote control + * } + * } + * + * Error Handling: + * - Returns false if device ID generation fails + * - Returns false if WiFi not connected + * - Wrapped in try-catch for safety + * - Device continues to operate if this fails + * + * Requirements: + * - WiFi must be connected (not just initialized) + * - Sufficient heap memory for WebSocket (~2KB) + * + * @return true if initialization successful, false on error + */ + bool begin(Config* config); + + /** + * Set the callback for setting device properties + * This callback will be invoked when commands are received from the service + * + * Example: + * control.onSetProperty([](const String& property, const String& value) { + * setSpaProperty(property, value); + * return true; + * }); + * + * @param callback Function to call when a property should be set + */ + void onSetProperty(SetPropertyCallback callback); + + /** + * Set the callback for connection events + * This callback will be invoked when WebSocket connection is established + * + * Example: + * control.onConnected([]() { + * String json = generateStatusJson(); + * control.publishState(json); + * }); + * + * @param callback Function to call when connection is established + */ + void onConnected(ConnectionCallback callback); + + /** + * Publish device state to connected WebSocket clients + * + * Call this whenever your device state changes and you want to notify + * remote clients. Pass the JSON string that you're already generating + * for MQTT (or similar). + * + * Example: + * String json = generateStatusJson(); + * control.publishState(json); + * + * @param stateJson JSON string containing complete device state + * @return true if state was published successfully, false otherwise + */ + bool publishState(const String& stateJson); + + /** + * loop() - Main processing function that must be called regularly + * + * **CRITICAL: Call this function in your main loop()!** + * + * This function handles all asynchronous operations: + * - WebSocket client cleanup + * - Periodic ping/pong keep-alive (every 30 seconds) + * - Automatic reconnection with exponential backoff + * - Connection state management + * + * Call Frequency: + * - Should be called at least once per loop iteration + * - Typical call frequency: every 10-50ms + * - More frequent calls = faster reconnection + * - Less frequent calls = lower CPU usage + * + * Processing Time: + * - Normally: < 1ms (just checks timers) + * - During reconnection: < 10ms + * - During ping: < 5ms + * - Non-blocking: never delays your code + * + * Reconnection Logic: + * When disconnected, automatically attempts reconnection using exponential backoff: + * - 1st attempt: after 1 second + * - 2nd attempt: after 2 seconds + * - 3rd attempt: after 4 seconds + * - ... doubles each time up to 60 seconds maximum + * - Resets to 1 second on successful connection + * + * Example Usage: + * void loop() { + * control.loop(); // Always call this first! + * + * // Your device logic here + * readSensors(); + * updateOutputs(); + * handleButtons(); + * + * delay(10); + * } + * + * Error Handling: + * - All operations wrapped in try-catch + * - Errors logged but never crash device + * - Failed operations are retried automatically + * - Library continues functioning even after errors + * + * Thread Safety: + * - Not thread-safe + * - Must be called from main loop only + * - Do not call from ISR or separate task + */ + void loop(); + + /** + * Check if the library is connected to the control service + * + * @return true if connected, false otherwise + */ + bool isConnected() const; + + /** + * Enable or disable debug logging + * + * @param enable true to enable debug output, false to disable + */ + void setDebug(bool enable); + + /** + * Set the authentication token received after pairing + * + * This token is sent with the WebSocket connection to authenticate the device. + * Call this after successful pairing or load from config on startup. + * + * @param token Authentication token from pairing process + */ + void setAuthToken(const String& token); + + /** + * Check if device has valid authentication token + * + * @return true if auth token is set, false otherwise + */ + bool hasAuthToken() const; + + /** + * Clear authentication token (unpair device) + * + * Disconnects from cloud service and clears stored token. + * Device will need to be re-paired. + */ + void clearAuthToken(); + +private: + // ==================== WebSocket Management ==================== + + /** + * _ws - WebSocket client for connection to cloud service + * Created in begin(), connects to wss://control.espa.diy/ws/device/{deviceId} + */ + WebsocketsClient _ws; + + /** + * _serverUrl - Cloud service URL + * Configurable via ESPA_CONTROL_SERVER_URL build flag + */ + String _serverUrl; + + // ==================== Configuration ==================== + + /** + * _deviceId - Unique device identifier (12 hex chars from MAC) + * Format: aabbccddeeff + * Generated once in begin(), stable across reboots + */ + String _deviceId; + + /** + * _authToken - Authentication token from pairing process + * Sent with WebSocket connection for authentication + * Empty string if not paired + */ + String _authToken; + + /** + * _debugEnabled - Flag to enable/disable debug logging to Serial + * Set via setDebug(). When true, logs all operations. + */ + bool _debugEnabled; + + // ==================== Connection State ==================== + + /** + * _connected - Current WebSocket connection state + * true: At least one client connected + * false: No clients connected (will attempt reconnection) + */ + bool _connected; + + /** + * _lastPingTime - Timestamp (millis) of last ping sent + * Used to trigger periodic keep-alive pings + */ + uint32_t _lastPingTime; + + /** + * _lastReconnectAttempt - Timestamp (millis) of last reconnection attempt + * Used with exponential backoff to space out reconnection tries + */ + uint32_t _lastReconnectAttempt; + + /** + * _reconnectAttempts - Counter of consecutive failed reconnection attempts + * Resets to 0 on successful connection + * Used for logging and debugging + */ + uint32_t _reconnectAttempts; + + /** + * _currentReconnectDelay - Current delay (ms) before next reconnection + * Starts at BASE_RECONNECT_DELAY (1000ms) + * Doubles on each failure up to MAX_RECONNECT_DELAY (60000ms) + * Resets on successful connection + */ + uint32_t _currentReconnectDelay; + + /** + * _lastErrorTime - Timestamp (millis) of last error log + * Used for rate-limiting error messages to avoid serial spam + */ + uint32_t _lastErrorTime; + + /** + * _consecutiveErrors - Counter of consecutive errors + * Resets on any successful operation + * After MAX_CONSECUTIVE_ERRORS, backoff increases significantly + */ + uint32_t _consecutiveErrors; + + // ==================== Timing Constants ==================== + + /** PING_INTERVAL - Time (ms) between keep-alive pings when connected */ + static const uint32_t PING_INTERVAL = 30000; // 30 seconds + + /** BASE_RECONNECT_DELAY - Initial delay (ms) before first reconnection attempt */ + static const uint32_t BASE_RECONNECT_DELAY = 5000; // 5 seconds + + /** MAX_RECONNECT_DELAY - Maximum delay (ms) between reconnection attempts */ + static const uint32_t MAX_RECONNECT_DELAY = 60000; // 60 seconds + + /** ERROR_COOLDOWN - Minimum time (ms) between error log messages */ + static const uint32_t ERROR_COOLDOWN = 10000; // 10 seconds + + /** MAX_CONSECUTIVE_ERRORS - Error count threshold for aggressive backoff */ + static const uint32_t MAX_CONSECUTIVE_ERRORS = 10; + + /** Tracking variables for reducing log spam */ + uint32_t _lastLoggedDelay; + bool _hasLoggedDisconnect; + + // ==================== Callbacks ==================== + + /** + * _setPropertyCallback - User-provided function to set device properties + * Registered via onSetProperty() + * nullptr if not registered + */ + SetPropertyCallback _setPropertyCallback; + + /** + * _connectionCallback - User-provided function called on connection + * Registered via onConnected() + * nullptr if not registered + */ + ConnectionCallback _connectionCallback; + + // ==================== Pairing State ==================== + + /** + * pairingState - Current state in the pairing flow + */ + PairingState pairingState; + + /** + * deviceId - Device ID in format ESPA-{MAC} (e.g., ESPA-B43A45B946BC) + */ + String deviceId; + + /** + * _config - Pointer to Config instance for persistent token storage + */ + Config* _config; + + /** + * lastPollTime - Timestamp of last pairing status poll + */ + unsigned long lastPollTime; + + /** + * pollInterval - Current interval between pairing status polls (ms) + */ + int pollInterval; + + /** + * pollAttempts - Number of pairing status poll attempts + */ + int pollAttempts; + + /** + * serverHost - Host portion of server URL (e.g., "10.0.0.198") + */ + String serverHost; + + /** + * serverPort - Port portion of server URL (e.g., 8080) + */ + int serverPort; + + // ==================== Pairing Flow Management ==================== + + /** + * checkPairingStatus() - Poll server for pairing approval status + * + * HTTP GET to /api/device/pairing-status/{deviceId} + * Response: + * { + * "approved": true, + * "token": "auth-token-string" + * } + * + * Called from loop() when in POLLING state. + * Implements exponential backoff (5s → 10s → 30s). + * On approval: saves token to NVS, transitions to PAIRED, connects WebSocket. + * On timeout: transitions to PAIRING_ERROR. + */ + void checkPairingStatus(); + + /** + * savePairingToken() - Persist auth token to NVS storage + * + * Uses Preferences library to save: + * - "authToken": authentication token string + * - "deviceId": device identifier + * + * Namespace: "espaControl" + * + * @param token Authentication token received from server + */ + void savePairingToken(const String& token); + + /** + * loadPairingToken() - Load auth token from NVS storage + * + * Called during begin() to check for existing pairing. + * If token found: sets PAIRED state, deviceId, authToken. + * If not found: sets NOT_PAIRED state. + * + * @return true if token loaded successfully + */ + bool loadPairingToken(); + + /** + * clearPairingToken() - Remove stored auth token from NVS + * + * Called when unpairing device or pairing fails. + * Clears "authToken" and "deviceId" from Preferences. + * Transitions to NOT_PAIRED state. + */ + void clearPairingToken(); + + /** + * parseServerUrl() - Extract host and port from server URL + * + * Parses ESPA_CONTROL_SERVER_URL to populate: + * - serverHost (e.g., "10.0.0.198") + * - serverPort (e.g., 8080) + * + * Handles http:// and https:// schemes. + * Called once during begin(). + */ + void parseServerUrl(); + + // ==================== WebSocket Management ==================== + + /** + * onMessage() - Handle incoming WebSocket message + * + * Called by ArduinoWebsockets library when message received. + * Parses JSON and routes to appropriate handler. + * + * @param message WebSocket message event + */ + void onMessage(WebsocketsMessage message); + + /** + * onEvent() - Handle WebSocket connection events + * + * Processes: + * - ConnectionOpened: Connected to cloud (reset backoff) + * - ConnectionClosed: Disconnected (start reconnection) + * - GotPing: Ping received (send pong) + * - GotPong: Pong received (reset error counter) + * + * @param event WebSocket event type + * @param data Optional event data + */ + void onEvent(WebsocketsEvent event, String data); + + // ==================== Message Processing ==================== + + /** + * handleWebSocketMessage() - Parse and route incoming WebSocket messages + * + * Message Format (JSON): + * { + * "type": "command" | "stateRequest" | "ping", + * ... type-specific fields ... + * } + * + * Processing: + * 1. Parses JSON (handles malformed JSON gracefully) + * 2. Routes to appropriate handler based on "type" field + * 3. Logs unknown message types + * + * Supported Message Types: + * - "command": Remote command to execute -> handleCommand() + * - "stateRequest": Request for current state -> handleStateRequest() + * - "ping": Keep-alive ping -> send pong response + * + * Error Handling: + * - JSON parse errors: logged, message discarded + * - Missing type field: logged, message discarded + * - Unknown type: logged, message discarded + * - All wrapped in try-catch + * + * @param data Message data string + */ + void handleWebSocketMessage(const String& data); + + /** + * handleCommand() - Execute property changes from the service + * + * Message Format: + * { + * "type": "command", + * "properties": { + * "SetTemp": "38.5", + * "pump1_state": "ON" + * } + * } + * + * Processing: + * 1. Validates setProperty callback is registered + * 2. Extracts "properties" object from message + * 3. For each property, invokes user's callback (protected by try-catch) + * 4. Sends acknowledgment to service with success/failure + * + * Command Acknowledgment Format: + * { + * "type": "commandAck", + * "deviceId": "aabbccddeeff", + * "success": true/false, + * "timestamp": 12345 + * } + * + * @param doc Parsed JSON document containing the message + */ + void handleCommand(const JsonDocument& doc); + + /** + * handleStateRequest() - Handle request for current device state + * + * Simply calls sendState() to transmit current state to service. + * Wrapped in try-catch for safety. + */ + void handleStateRequest(); + + // ==================== Connection Management ==================== + + /** + * connectWebSocket() - Placeholder for connection logic + * + * Currently just logs status. Actual connection is managed by + * AsyncWebSocket library. Future: could trigger manual connection. + */ + void connectWebSocket(); + + /** + * sendPing() - Send keep-alive ping to maintain connection + * + * Ping Message Format: + * { + * "type": "ping", + * "deviceId": "aabbccddeeff", + * "timestamp": 12345 + * } + * + * Called automatically every PING_INTERVAL (30s) from loop(). + * Resets error counter on success. + * Wrapped in try-catch. + */ + void sendPing(); + + // ==================== Error Handling & Resilience ==================== + + /** + * handleError() - Process and log an error + * + * Actions: + * 1. Increments consecutive error counter + * 2. Logs error (rate-limited) + * 3. If errors exceed threshold, max out backoff delay + * + * @param error Error message to log + */ + void handleError(const char* error); + + /** + * resetReconnectDelay() - Reset exponential backoff to initial values + * + * Called when connection succeeds. + * Resets: + * - Reconnection delay to BASE_RECONNECT_DELAY (1s) + * - Attempt counter to 0 + * - Error counter to 0 + */ + void resetReconnectDelay(); + + /** + * increaseReconnectDelay() - Apply exponential backoff + * + * Called when connection/operation fails. + * Doubles current delay up to MAX_RECONNECT_DELAY (60s). + * Increments attempt counter. + */ + void increaseReconnectDelay(); + + /** + * shouldAttemptReconnect() - Check if enough time passed for reconnect + * + * Returns true if: + * - Current time - last attempt >= current delay + * + * Used by loop() to implement exponential backoff. + * + * @return true if reconnection should be attempted now + */ + bool shouldAttemptReconnect(); + + // ==================== Utility & Logging ==================== + + /** + * log() - Log debug message if debug mode enabled + * + * Format: [EspaControl] message + * Only outputs if setDebug(true) was called. + * + * @param message C-string message to log + */ + void log(const char* message); + + /** + * log() - Log debug message (String overload) + * + * @param message Arduino String to log + */ + void log(const String& message); + + /** + * logError() - Log error with rate limiting + * + * Format: [EspaControl ERROR] message + * Rate-limited to one message per ERROR_COOLDOWN (5s). + * Always outputs (regardless of debug setting). + * + * @param error Error message to log + */ + void logError(const char* error); + + /** + * generateDeviceId() - Create unique device ID from MAC address + * + * Reads WiFi MAC address and converts to 12-character hex string. + * Format: aabbccddeeff (lowercase, no separators) + * + * Example: + * MAC: AA:BB:CC:DD:EE:FF + * ID: aabbccddeeff + * + * Called once during begin(). + * + * @return Device ID string, or empty string on error + */ + String generateDeviceId(); +}; + +#endif // ESPA_CONTROL_H diff --git a/platformio.ini b/platformio.ini index 64538e2..1603c0c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -23,7 +23,7 @@ lib_deps = tzapu/WiFiManager@^2.0.17 knolleary/PubSubClient@^2.8 bblanchon/ArduinoJson@^7.4.2 - ;links2004/WebSockets@^2.7.1 + gilmaimon/ArduinoWebsockets@^0.5.4 paulstoffregen/Time@^1.6.1 https://github.com/me-no-dev/ESPAsyncWebServer.git extra_scripts = @@ -43,6 +43,7 @@ build_flags = [env:espa-v1] extends = env:spa-base board = esp32-s3-devkitc-1 +lib_ldf_mode = deep build_flags = -D ARDUINO_USB_CDC_ON_BOOT=1 -D ESPA_V1=1 @@ -50,6 +51,10 @@ build_flags = -D TX_PIN=15 -D EN_PIN=0 -D SPA_SERIAL=Serial2 + -D ENABLE_ESPA_CONTROL + -D DESKTOP_TEST_MODE + -DDEV_WIFI_SSID=\"Giles\ -\ IoT\" + -DDEV_WIFI_PASSWORD=\"\" [env:espa-v2] extends = env:spa-base diff --git a/src/main.cpp b/src/main.cpp index 01cc5f9..3a73716 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,10 @@ #include "HAAutoDiscovery.h" #include "MQTTClientWrapper.h" +#ifdef ENABLE_ESPA_CONTROL +#include +#endif + unsigned long bootStartMillis; // To track when the device started RemoteDebug Debug; @@ -64,6 +68,18 @@ bool setSpaCallbackReady = false; String spaCallbackProperty; String spaCallbackValue; +#ifdef ENABLE_ESPA_CONTROL + EspaControl espaControl; +#endif + +// Desktop test mode - set to true to test without spa connection +// Allows testing of web UI, eSpa Control pairing, etc. on desktop +#ifdef DESKTOP_TEST_MODE + const bool desktopTestMode = true; +#else + const bool desktopTestMode = false; +#endif + void WMsaveConfigCallback(){ WMsaveConfig = true; } @@ -448,6 +464,12 @@ void mqttPublishStatus() { String json; if (generateStatusJson(si, mqttClient, json, false)) { mqttClient.publish(mqttStatusTopic.c_str(),json.c_str()); + + #ifdef ENABLE_ESPA_CONTROL + if (espaControl.isConnected()) { + espaControl.publishState(json); + } + #endif } else { debugD("Error generating json"); } @@ -605,6 +627,7 @@ void setup() { #endif Serial.begin(115200); + delay(100); // Give Serial time to initialize Serial.setDebugOutput(true); Debug.setSerialEnabled(true); @@ -620,8 +643,13 @@ void setup() { debugA("Starting ESP..."); if (!config.readConfig()) { - debugA("Failed to open config.json, starting Wi-Fi Manager"); - startWiFiManager(); + #if !defined(DEV_WIFI_SSID) && !defined(DEV_WIFI_PASSWORD) + // Only start WiFi Manager if we don't have hardcoded dev credentials + debugA("Failed to open config.json, starting Wi-Fi Manager"); + startWiFiManager(); + #else + Serial.println("Config: Skipping WiFi Manager (using dev credentials)"); + #endif } blinker.setState(STATE_WIFI_NOT_CONNECTED); @@ -635,8 +663,18 @@ void setup() { WiFi.mode(WIFI_STA); } - //WiFi.begin(config.WiFiSSID.getValue().c_str(), config.WiFiPassword.getValue().c_str()); - WiFi.begin(); + // Development WiFi credentials (skip WiFi Manager) + #ifdef DEV_WIFI_SSID + #ifdef DEV_WIFI_PASSWORD + WiFi.begin(DEV_WIFI_SSID, DEV_WIFI_PASSWORD); + #else + Serial.println("WiFi: DEV_WIFI_PASSWORD is NOT defined"); + WiFi.begin(DEV_WIFI_SSID, ""); + #endif + #else + WiFi.begin(); + #endif + if (WiFi.waitForConnectResult() == WL_CONNECTED) { debugI("Connected to Wi-Fi as %s", WiFi.getHostname()); int totalTry = 5; @@ -675,13 +713,150 @@ void setup() { config.setCallback(configChangeCallbackInt); config.setCallback(configChangeCallbackBool); + #ifdef ENABLE_ESPA_CONTROL + // Register callbacks BEFORE begin() so they're available during initial connection + espaControl.setDebug(true); + + espaControl.onSetProperty([](const String& property, const String& value) { + setSpaProperty(property, value); // Call existing function! + return true; + }); + + // Publish state immediately when connected + espaControl.onConnected([]() { + debugI("eSpa Control connected - attempting to publish initial state"); + + // Check if spa is initialized + if (spaSerialNumber == "") { + debugW("Spa not yet initialized - will publish on first status update"); + + // In desktop test mode, send a minimal test status + if (desktopTestMode) { + String testJson = "{\"status\":\"desktop_test_mode\",\"deviceId\":\"" + + espaControl.getDeviceId() + "\"}"; + debugI("Publishing test status for desktop mode"); + espaControl.publishState(testJson); + } + return; + } + + // Try to publish current state + String json; + if (generateStatusJson(si, mqttClient, json, false)) { + if (espaControl.isConnected()) { + debugI("Publishing initial state to eSpa Control"); + espaControl.publishState(json); + } else { + debugW("eSpa Control not marked as connected yet"); + } + } else { + debugW("Failed to generate status JSON"); + } + }); + + // Initialize eSpa Control - callbacks are now registered and will fire during connection + // Uses Config for pairing token storage + if (espaControl.begin(&config)) { + // Token is now loaded automatically from config in begin() + // Use serial commands to pair: "pair 123456", "unpair", "status", "help" + + // Pass EspaControl instance to WebUI for pairing support + ui.setEspaControl(&espaControl); + + debugI("eSpa Control initialized"); + debugI("Device ID: %s", espaControl.getDeviceId().c_str()); + + if (espaControl.isPaired()) { + debugI("Device is paired - will connect to cloud service"); + } else { + debugI("Device needs pairing - use serial command: pair "); + } + } + #endif } +#ifdef ENABLE_ESPA_CONTROL +// Handle serial commands for EspaControl pairing +void handleSerialCommands() { + static String serialBuffer = ""; + + while (Serial.available()) { + char c = Serial.read(); + + if (c == '\n' || c == '\r') { + if (serialBuffer.length() > 0) { + String cmd = serialBuffer; + cmd.trim(); + serialBuffer = ""; + + // Handle commands + if (cmd.startsWith("pair ")) { + String code = cmd.substring(5); + code.trim(); + + if (code.length() == 6) { + debugI("Submitting pairing code: %s", code.c_str()); + if (espaControl.submitPairingCode(code)) { + debugI("Pairing code submitted successfully"); + } else { + debugE("Failed to submit pairing code"); + } + } else { + debugE("Invalid pairing code. Must be 6 digits. Usage: pair 123456"); + } + } + else if (cmd == "unpair") { + debugI("Clearing pairing token..."); + espaControl.unpair(); + debugI("Device unpaired"); + } + else if (cmd == "status") { + debugI("=== EspaControl Status ==="); + debugI("Device ID: %s", espaControl.getDeviceId().c_str()); + + PairingState state = espaControl.getPairingState(); + String stateStr; + switch(state) { + case NOT_PAIRED: stateStr = "NOT_PAIRED"; break; + case CODE_SUBMITTED: stateStr = "CODE_SUBMITTED"; break; + case POLLING: stateStr = "POLLING"; break; + case PAIRED: stateStr = "PAIRED"; break; + case PAIRING_ERROR: stateStr = "PAIRING_ERROR"; break; + default: stateStr = "UNKNOWN"; break; + } + debugI("Pairing State: %s", stateStr.c_str()); + debugI("Is Paired: %s", espaControl.isPaired() ? "YES" : "NO"); + debugI("Connected: %s", espaControl.isConnected() ? "YES" : "NO"); + } + else if (cmd == "help") { + debugI("=== EspaControl Commands ==="); + debugI("pair - Submit 6-digit pairing code (e.g., pair 123456)"); + debugI("unpair - Clear pairing token and reset device"); + debugI("status - Show current pairing status"); + debugI("help - Show this help message"); + } + } + } else { + serialBuffer += c; + + // Prevent buffer overflow + if (serialBuffer.length() > 100) { + serialBuffer = ""; + } + } + } +} +#endif + void loop() { checkButton(); // Check if the button is pressed to start Wi-Fi Manager Debug.handle(); + + #ifdef ENABLE_ESPA_CONTROL + handleSerialCommands(); // Handle serial commands for pairing + #endif if (setSpaCallbackReady) { if (spaCallbackProperty == "reboot") { @@ -727,17 +902,27 @@ void loop() { if (delayedStart) { delayedStart = !(bootTime + 10000 < millis()); } else { - si.loop(); + // In desktop test mode, skip spa interface entirely + if (!desktopTestMode) { + si.loop(); + } - if (!si.isInitialised()) { + if (!desktopTestMode && !si.isInitialised()) { // set status lights to indicate we are waiting for spa connection before we proceed blinker.setState(STATE_WAITING_FOR_SPA); } else { if ( spaSerialNumber=="" ) { debugI("Initialising..."); - spaSerialNumber = si.getSerialNo1()+"-"+si.getSerialNo2(); - debugI("Spa serial number is %s",spaSerialNumber.c_str()); + if (desktopTestMode) { + // Use MAC address as serial number in test mode + spaSerialNumber = WiFi.macAddress(); + spaSerialNumber.replace(":", ""); + debugI("Desktop test mode - using MAC as serial: %s",spaSerialNumber.c_str()); + } else { + spaSerialNumber = si.getSerialNo1()+"-"+si.getSerialNo2(); + debugI("Spa serial number is %s",spaSerialNumber.c_str()); + } mqttBase = String("sn_esp32/") + spaSerialNumber + String("/"); mqttStatusTopic = mqttBase + "status"; @@ -745,7 +930,7 @@ void loop() { mqttAvailability = mqttBase+"available"; debugI("MQTT base topic is %s",mqttBase.c_str()); } - if (!mqttClient.connected()) { // MQTT broker reconnect if not connected + if (!desktopTestMode && !mqttClient.connected()) { // MQTT broker reconnect if not connected (skip in test mode) long now=millis(); if (now - mqttLastConnect > 1000) { blinker.setState(STATE_MQTT_NOT_CONNECTED); @@ -768,10 +953,9 @@ void loop() { } else { debugW("MQTT connection failed"); } - } } else { - if (!autoDiscoveryPublished) { // This is the setup area, gets called once when communication with Spa and MQTT broker have been established. + if (!desktopTestMode && !autoDiscoveryPublished) { // This is the setup area, gets called once when communication with Spa and MQTT broker have been established. debugI("Publish autodiscovery information"); mqttHaAutoDiscovery(); autoDiscoveryPublished = true; @@ -780,15 +964,27 @@ void loop() { si.statusResponse.setCallback(mqttPublishStatusString); } + + #ifdef ENABLE_ESPA_CONTROL + espaControl.loop(); // Process WebSocket events + #endif // all systems are go! Start the knight rider animation loop blinker.setState(KNIGHT_RIDER); } + + // In desktop test mode, we're always "ready" after delayed start + if (desktopTestMode) { + #ifdef ENABLE_ESPA_CONTROL + espaControl.loop(); // Process WebSocket events for pairing testing + #endif + blinker.setState(KNIGHT_RIDER); + } } } } - if (updateMqtt) { + if (!desktopTestMode && updateMqtt) { debugD("Changing MQTT settings..."); mqttClient.disconnect(); mqttClient.setServer(config.MqttServer.getValue(), config.MqttPort.getValue()); @@ -811,5 +1007,7 @@ void loop() { updateSoftAP = false; } - mqttClient.loop(); + if (!desktopTestMode) { + mqttClient.loop(); + } } \ No newline at end of file