#include #include #include #include #include "max6675.h" #include "temperature.h" #include "wifi_config.h" #include "device_token.h" // ── Pin definitions ─────────────────────────────────────────────────────────── static constexpr int BT_DO = 19, BT_CS = 23, BT_CLK = 5; // Bean Temp static constexpr int ET_DO = 33, ET_CS = 25, ET_CLK = 26; // Exhaust Temp // ── Timing / limits ─────────────────────────────────────────────────────────── static constexpr unsigned long READ_INTERVAL_MS = 1000UL; static constexpr unsigned long HEAP_LOG_INTERVAL = 30000UL; static constexpr uint32_t HEAP_WARN_BYTES = 10000UL; static constexpr unsigned long STA_TIMEOUT_MS = 15000UL; static constexpr unsigned long TOKEN_RETRY_INTERVAL_MS = 60000UL; // retry every 1 min until token received static constexpr unsigned long TOKEN_DAILY_CHECK_MS = 24 * 3600000UL; // re-validate every 24 h // ── Provisioning AP name (open network — no password) ───────────────────────── static constexpr const char* PROV_AP_SSID = "RoastIQ"; // ── Hardware objects ────────────────────────────────────────────────────────── static MAX6675 sensorBT(BT_CLK, BT_CS, BT_DO); static MAX6675 sensorET(ET_CLK, ET_CS, ET_DO); static AsyncWebServer server(80); static WiFiUDP dnsUdp; static bool g_provisioning = false; // true while in AP captive-portal mode // ── Minimal captive-portal DNS responder ──────────────────────────────────── static void handleDnsRequest() { int len = dnsUdp.parsePacket(); if (len < 12) return; uint8_t buf[512]; int n = dnsUdp.read(buf, sizeof(buf)); if (n < 12) return; buf[2] = 0x81; buf[3] = 0x80; buf[6] = 0x00; buf[7] = 0x01; int pos = 12; while (pos < n && buf[pos] != 0) pos += buf[pos] + 1; pos += 5; // Guard against a malformed or oversized DNS question overflowing buf // when the answer record is appended (answer is 16 bytes). if (pos < 0 || pos + 16 > (int)sizeof(buf)) return; IPAddress apIp = WiFi.softAPIP(); uint8_t answer[] = { 0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x04, apIp[0], apIp[1], apIp[2], apIp[3] }; memcpy(buf + pos, answer, sizeof(answer)); dnsUdp.beginPacket(dnsUdp.remoteIP(), dnsUdp.remotePort()); dnsUdp.write(buf, pos + sizeof(answer)); dnsUdp.endPacket(); } // ── Shared state ────────────────────────────────────────────────────────────── static portMUX_TYPE dataMux = portMUX_INITIALIZER_UNLOCKED; static volatile float g_bt = 0.0f; static volatile float g_et = 0.0f; // ── Rolling averages (3-sample) ─────────────────────────────────────────────── static RollingAverage<3> btAvg; static RollingAverage<3> etAvg; // ── Timers ──────────────────────────────────────────────────────────────────── static unsigned long lastReadMs = 0; static unsigned long lastHeapMs = 0; static unsigned long lastTokenRetryMs = 0; static unsigned long lastTokenCheckMs = 0; // ── Server URL, MAC address, and device name (loaded at boot) ─────────────── static char g_macAddr[20] = {0}; static char g_serverUrl[128] = {0}; static char g_deviceName[64] = {0}; // ── Cached local IP ─────────────────────────────────────────────────────────── static volatile uint8_t g_ip[4] = {0, 0, 0, 0}; // ── Provisioning HTML ───────────────────────────────────────────────────────── static const char PROV_HTML[] PROGMEM = R"rawhtml( RoastIQ WiFi Setup

WiFi Setup

)rawhtml"; // ── Device Setup HTML ───────────────────────────────────────────────────────── static const char SETUP_HTML[] PROGMEM = R"rawhtml( RoastIQ Setup

Device Setup

MAC: loading...
)rawhtml"; // ── WiFi event handler ──────────────────────────────────────────────────────── static void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t /*info*/) { if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { IPAddress ip = WiFi.localIP(); g_ip[0] = ip[0]; g_ip[1] = ip[1]; g_ip[2] = ip[2]; g_ip[3] = ip[3]; Serial.printf("[WiFi] IP: %d.%d.%d.%d\n", (int)g_ip[0], (int)g_ip[1], (int)g_ip[2], (int)g_ip[3]); } else if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { Serial.println("[WiFi] Disconnected — auto-reconnect pending"); } } // ── Normal operation server (STA mode) ─────────────────────────────────────── static void startMainServer() { server.on("/reset", HTTP_GET, [](AsyncWebServerRequest* req) { clearWifiCredentials(); req->send(200, "text/plain", "Credentials cleared. Rebooting..."); delay(500); ESP.restart(); }); server.on("/setup", HTTP_GET, [](AsyncWebServerRequest* req) { req->send_P(200, "text/html", SETUP_HTML); }); server.on("/setup-info", HTTP_GET, [](AsyncWebServerRequest* req) { StaticJsonDocument<256> doc; doc["mac"] = WiFi.macAddress(); doc["deviceName"] = g_deviceName; doc["serverUrl"] = g_serverUrl; char buf[256]; size_t len = serializeJson(doc, buf, sizeof(buf)); req->send(200, "application/json", String(buf)); }); server.on("/setup-save", HTTP_POST, [](AsyncWebServerRequest* req) { String deviceName, serverUrl; if (req->hasParam("device_name", true)) { deviceName = req->getParam("device_name", true)->value(); deviceName.trim(); } if (deviceName.length() == 0) { req->send(400, "text/html", "

Error: Device Name is required.

Back"); return; } if (req->hasParam("server_url", true)) { serverUrl = req->getParam("server_url", true)->value(); } if (serverUrl.length() == 0) { req->send(400, "text/html", "

Error: Server URL is required.

Back"); return; } if (serverUrl.endsWith("/")) { serverUrl.remove(serverUrl.length() - 1); } saveDeviceName(deviceName); strncpy(g_deviceName, deviceName.c_str(), sizeof(g_deviceName) - 1); g_deviceName[sizeof(g_deviceName) - 1] = '\0'; Serial.printf("[Setup] Device name saved: %s\n", g_deviceName); saveServerUrl(serverUrl); strncpy(g_serverUrl, serverUrl.c_str(), sizeof(g_serverUrl) - 1); g_serverUrl[sizeof(g_serverUrl) - 1] = '\0'; Serial.printf("[Setup] Server URL saved: %s\n", g_serverUrl); bool registered = false; if (strlen(g_macAddr) > 0) { registered = registerDevice(g_serverUrl, g_macAddr, g_deviceName); } String status = registered ? "

Device registered on server. An admin must assign it to an organisation before it can stream data.

" : "

Registration request failed — check server URL and connectivity.

"; req->send(200, "text/html", "

Configuration saved!

" "

Device: " + deviceName + "

" "

Server URL: " + serverUrl + "

" + status + "Back to setup"); }); server.begin(); Serial.println("[HTTP] Server started"); } // ── Provisioning AP mode ────────────────────────────────────────────────────── static void startProvisioningAP() { WiFi.mode(WIFI_AP); WiFi.softAP(PROV_AP_SSID); Serial.printf("[Prov] AP \"%s\" started — connect and browse to %s\n", PROV_AP_SSID, WiFi.softAPIP().toString().c_str()); int found = WiFi.scanNetworks(); Serial.printf("[Prov] Found %d network(s)\n", found); static String g_scanJson; { int n = (found > 0) ? min(found, 20) : 0; DynamicJsonDocument jdoc(2048); JsonArray arr = jdoc.to(); for (int i = 0; i < n; ++i) { JsonObject obj = arr.createNestedObject(); obj["ssid"] = WiFi.SSID(i); obj["rssi"] = WiFi.RSSI(i); } serializeJson(jdoc, g_scanJson); } WiFi.scanDelete(); server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) { req->send_P(200, "text/html", PROV_HTML); }); server.on("/scan", HTTP_GET, [](AsyncWebServerRequest* req) { req->send(200, "application/json", g_scanJson); }); server.on("/config", HTTP_POST, [](AsyncWebServerRequest* req) { String ssid, pass; if (req->hasParam("ssid_manual", true) && req->getParam("ssid_manual", true)->value().length() > 0) { ssid = req->getParam("ssid_manual", true)->value(); } else if (req->hasParam("ssid", true)) { ssid = req->getParam("ssid", true)->value(); } pass = req->hasParam("pass", true) ? req->getParam("pass", true)->value() : ""; if (ssid.length() == 0) { req->send(400, "text/html", "

Error: no SSID provided.

Back"); return; } if (ssid.length() > 32) { req->send(400, "text/html", "

Error: SSID too long (max 32 chars).

Back"); return; } if (pass.length() > 63) { req->send(400, "text/html", "

Error: Password too long (max 63 chars).

Back"); return; } saveWifiCredentials(ssid, pass); Serial.printf("[Prov] Credentials saved — SSID: %s\n", ssid.c_str()); req->send(200, "text/html", "

Saved! Connecting to " + ssid + "

" "

The device will reboot and join your network. " "You can reconnect to it on your WiFi after a few seconds.

"); delay(1500); ESP.restart(); }); server.onNotFound([](AsyncWebServerRequest* req) { req->redirect("http://192.168.4.1"); }); server.begin(); dnsUdp.begin(53); g_provisioning = true; Serial.println("[Prov] Config server + captive portal started"); } // ── Arduino entry points ────────────────────────────────────────────────────── void setup() { Serial.begin(115200); Serial.printf("\n[BOOT] Free heap: %u bytes\n", ESP.getFreeHeap()); getMacAddress(g_macAddr, sizeof(g_macAddr)); Serial.printf("[BOOT] MAC: %s\n", g_macAddr); String savedDeviceName = loadDeviceName(); if (savedDeviceName.length() > 0) { strncpy(g_deviceName, savedDeviceName.c_str(), sizeof(g_deviceName) - 1); g_deviceName[sizeof(g_deviceName) - 1] = '\0'; Serial.printf("[BOOT] Device name: %s\n", g_deviceName); } else { Serial.println("[BOOT] Device name not set — configure via /setup"); } String savedServerUrl; if (loadServerUrl(savedServerUrl)) { strncpy(g_serverUrl, savedServerUrl.c_str(), sizeof(g_serverUrl) - 1); g_serverUrl[sizeof(g_serverUrl) - 1] = '\0'; Serial.printf("[BOOT] Server URL: %s\n", g_serverUrl); } else { Serial.println("[BOOT] Server URL not set — configure via /setup"); } String savedSsid, savedPass; if (loadWifiCredentials(savedSsid, savedPass)) { Serial.printf("[WiFi] Connecting to \"%s\"…\n", savedSsid.c_str()); WiFi.onEvent(onWifiEvent); WiFi.setAutoReconnect(true); WiFi.mode(WIFI_STA); WiFi.begin(savedSsid.c_str(), savedPass.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < STA_TIMEOUT_MS) { delay(500); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { if (g_ip[0] == 0) { IPAddress ip = WiFi.localIP(); g_ip[0] = ip[0]; g_ip[1] = ip[1]; g_ip[2] = ip[2]; g_ip[3] = ip[3]; Serial.printf("[WiFi] Connected — IP: %d.%d.%d.%d\n", (int)g_ip[0], (int)g_ip[1], (int)g_ip[2], (int)g_ip[3]); } loadTokenFromNVS(); if (!hasStoredToken() && strlen(g_serverUrl) > 0) { Serial.println("[Token] No stored token — fetching now, will retry every 1 min"); fetchAndStoreToken(g_serverUrl, g_macAddr); } lastTokenRetryMs = millis(); lastTokenCheckMs = millis(); startMainServer(); Serial.printf("[BOOT] Setup page at http://%d.%d.%d.%d/setup\n", (int)g_ip[0], (int)g_ip[1], (int)g_ip[2], (int)g_ip[3]); return; } Serial.println("[WiFi] Connection timed out — starting provisioning AP"); } else { Serial.println("[WiFi] No saved credentials — starting provisioning AP"); } startProvisioningAP(); } void loop() { if (g_provisioning) { handleDnsRequest(); } unsigned long now = millis(); if (now - lastReadMs >= READ_INTERVAL_MS) { lastReadMs = now; float rawBt = sensorBT.readCelsius(); float rawEt = sensorET.readCelsius(); bool btOk = isValidTemp(rawBt); bool etOk = isValidTemp(rawEt); if (btOk) btAvg.push(rawBt); if (etOk) etAvg.push(rawEt); float smoothBt = btAvg.value(); float smoothEt = etAvg.value(); taskENTER_CRITICAL(&dataMux); g_bt = smoothBt; g_et = smoothEt; taskEXIT_CRITICAL(&dataMux); Serial.printf("[TEMP] BT=%.2f°C ET=%.2f°C%s%s\n", smoothBt, smoothEt, btOk ? "" : " [BT FAULT]", etOk ? "" : " [ET FAULT]"); // Send telemetry to server if WiFi connected and token available if (WiFi.status() == WL_CONNECTED && strlen(g_serverUrl) > 0) { #ifdef ROASTIQ_DEV Serial.printf("[DEV] Sending telemetry → %s token=%s\n", g_serverUrl, hasStoredToken() ? "yes" : "no"); #endif int httpCode = sendTelemetry(g_serverUrl, g_macAddr, g_deviceName, smoothBt, smoothEt); if (httpCode == 401) { // Token rejected by server — clear it and fetch a fresh one immediately Serial.println("[Token] 401 on telemetry — clearing stale token, re-fetching now"); clearTokenFromNVS(); fetchAndStoreToken(g_serverUrl, g_macAddr); lastTokenRetryMs = millis(); } #ifdef ROASTIQ_DEV else if (httpCode != 200 && httpCode != 0) { Serial.printf("[DEV] Telemetry send failed (HTTP %d)\n", httpCode); } #endif } #ifdef ROASTIQ_DEV else { Serial.printf("[DEV] Telemetry skipped — WiFi=%s serverUrl=%s\n", WiFi.status() == WL_CONNECTED ? "OK" : "DISCONNECTED", strlen(g_serverUrl) > 0 ? g_serverUrl : "(not set)"); } #endif } // ── Token management ────────────────────────────────────────────────────── if (WiFi.status() == WL_CONNECTED && strlen(g_serverUrl) > 0) { if (!hasStoredToken() && (now - lastTokenRetryMs >= TOKEN_RETRY_INTERVAL_MS)) { lastTokenRetryMs = now; Serial.println("[Token] Retrying fetch..."); fetchAndStoreToken(g_serverUrl, g_macAddr); } if (hasStoredToken() && (now - lastTokenCheckMs >= TOKEN_DAILY_CHECK_MS)) { lastTokenCheckMs = now; Serial.println("[Token] Daily validity check..."); if (!checkTokenWithServer(g_serverUrl)) { reportDeviceError(g_serverUrl, g_macAddr, "TOKEN_INVALID", "Daily check: token rejected by server — clearing and retrying"); clearTokenFromNVS(); lastTokenRetryMs = now - TOKEN_RETRY_INTERVAL_MS; } } } // Periodic heap monitoring if (now - lastHeapMs >= HEAP_LOG_INTERVAL) { lastHeapMs = now; uint32_t freeHeap = ESP.getFreeHeap(); Serial.printf("[HEAP] Free: %u bytes\n", freeHeap); if (freeHeap < HEAP_WARN_BYTES) { Serial.println("[WARN] Heap critically low"); } } }