diff --git a/version/r1.2/firmware/esp32_firmware_1-2/api.h b/version/r1.2/firmware/esp32_firmware_1-2/api.h index d1580f2..6fc5f70 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/api.h +++ b/version/r1.2/firmware/esp32_firmware_1-2/api.h @@ -55,7 +55,15 @@ void connectWifiAP() { Serial.println("\nCreating access point..."); WiFi.mode(WIFI_AP); WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0)); - WiFi.softAP(HOSTNAME); + // WPA2 with the per-device MAC-derived password (printed at boot in + // setup()). Prevents anyone in radio range from joining and rewriting + // the user's stored WiFi credentials via the captive portal. + WiFi.softAP(HOSTNAME, espui_password.c_str()); + + // Resolve every host to the soft-AP IP so phones/laptops detect the + // captive portal and the user lands on the ESPUI page automatically. + // Paired with dnsServer.processNextRequest() in taskCore1. + dnsServer.start(53, "*", IPAddress(192, 168, 4, 1)); connect_timeout = 20; do { @@ -90,7 +98,8 @@ void connectWifi() { // try to connect to existing network Serial.println("\n\nTry to connect to existing network"); Serial.println(ssid); - Serial.println(password); + // Intentionally not logging `password` to avoid leaking the WiFi PSK + // to anyone with USB serial access. WiFi.begin(ssid, password); uint8_t timeout = 100; @@ -110,6 +119,48 @@ void connectWifi() { Serial.println(local_ip_address); } +/** + * Bring up ArduinoOTA so firmware can be flashed over WiFi. + * + * Only enabled in STA mode (the AP fallback is for initial setup, not + * for OTA). Reuses the per-device MAC-derived password as the OTA auth + * password so a LAN attacker can't push arbitrary firmware. Requires + * ArduinoOTA.handle() to be serviced from taskCore0 to actually accept + * uploads. + */ +void setupOTA() { + if (WiFi.getMode() != WIFI_STA || WiFi.status() != WL_CONNECTED) { + Serial.println("OTA disabled (not connected in STA mode)"); + return; + } + + ArduinoOTA.setHostname(HOSTNAME); + ArduinoOTA.setPassword(espui_password.c_str()); + + ArduinoOTA.onStart([]() { + Serial.println("OTA: upload starting"); + }); + ArduinoOTA.onEnd([]() { + Serial.println("\nOTA: upload complete, rebooting"); + }); + ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { + // Heartbeat the WDT during the long flash write so the upload doesn't + // trip the 30s timeout on slow links. + esp_task_wdt_reset(); + Serial.printf("OTA: %u%%\r", (progress * 100) / total); + }); + ArduinoOTA.onError([](ota_error_t error) { + Serial.printf("OTA error %u\n", error); + }); + + ArduinoOTA.begin(); + Serial.print("OTA ready at "); + Serial.print(WiFi.localIP()); + Serial.print(" (host "); + Serial.print(HOSTNAME); + Serial.println(")"); +} + /** * Tear down the WiFi radio entirely. * @@ -136,8 +187,10 @@ void wifiResetButton() { delay(3000); // delay 3 seconds if (digitalRead(WIFI_RESET_PIN) == LOW) { // Confirm button press Serial.println("Reset button pressed. Resetting Wi-Fi..."); - preferences.putString("ssid", "ssid"); // This replaces the stored wifi network with a random value - preferences.putString("pass", "pass"); // This replaces the stored wifi network with a random value + // Match the sentinel used by buttonClearNetworkCall so connectWifiAP + // takes the AP-fallback path on next boot. + preferences.putString("ssid", "NOT_SET"); + preferences.putString("pass", "NOT_SET"); ESP.restart(); } } @@ -175,8 +228,10 @@ void sendLocalIP() { //WiFiClient client; HTTPClient https; - String recv_token = "bc6d8bd23bbeb6b13fa67448c244a129"; // Complete Bearer token - recv_token = "Bearer " + recv_token; // Adding "Bearer " before token + // Bearer token is loaded from NVS (default seeded from the original + // baked-in value at first boot) so it can be rotated via the ESPUI + // portal without reflashing. + String recv_token = "Bearer " + api_token; // Sending POST request https.begin(*client, server_local_ip_address); @@ -256,7 +311,9 @@ void sendPhoto(void) { delay(100); Serial.println("waiting on data"); } - speed_actual = maxSpeed; + // Round to nearest int so the wire format stays "25" rather than "25.70" + // (Arduino's String += float defaults to two decimal places). + speed_actual = (int)(maxSpeed + 0.5f); String httpsRequestSend; @@ -269,7 +326,9 @@ void sendPhoto(void) { https.addHeader("Authorization", recv_token); // Adding Bearer token as HTTP header https.addHeader("Content-Type", "application/json"); // Adding Bearer token as HTTP header - httpsRequestData.reserve(150000); + // Reserve up the request buffer once: a UXGA JPEG base64-encodes to + // roughly 100KB+, and without this the String reallocates many times. + httpsRequestSend.reserve(150000); httpsRequestSend = "{\"send_photo\":\""; httpsRequestSend += send_photo_text; httpsRequestSend += "\",\"camera\":\""; @@ -314,6 +373,10 @@ void sendPhoto(void) { https.end(); delete client; + // Release the ~100KB base64 buffer back to the heap; otherwise it lingers + // until the next capture overwrites it. + photo_base64 = String(); + //Don't disconnect wifi during first two minutes of bootup if (wake_flag == false) { //disconnectWifi(); // Don't disconnect, just go to sleep diff --git a/version/r1.2/firmware/esp32_firmware_1-2/camera.h b/version/r1.2/firmware/esp32_firmware_1-2/camera.h index 7cc2bff..462c390 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/camera.h +++ b/version/r1.2/firmware/esp32_firmware_1-2/camera.h @@ -109,17 +109,23 @@ int cameraSetup(void) { /** * Capture a single JPEG frame and base64-encode it for upload. * - * Side effects: populates the globals `photo_filename` and `photo_base64` - * (defined in variables.h), which sendPhoto() then embeds in the JSON - * upload body. The frame buffer is returned to the driver before exit. + * Side effects: on success populates the globals `photo_filename` and + * `photo_base64` (defined in variables.h), which sendPhoto() then embeds + * in the JSON upload body. On failure, both globals are cleared so a + * stale photo from a previous run can never be re-uploaded. + * + * @return true on a valid frame buffer, false otherwise. */ -void takePhoto(void) { - camera_fb_t* fb = esp_camera_fb_get(); // Capture photo +bool takePhoto(void) { + camera_fb_t* fb = esp_camera_fb_get(); if (!fb) { - Serial.println("Camera capture failed 1"); - return; + Serial.println("Camera capture failed (esp_camera_fb_get returned NULL)"); + photo_filename = String(); + photo_base64 = String(); + return false; } photo_filename = "image.jpg"; photo_base64 = base64::encode(fb->buf, fb->len); esp_camera_fb_return(fb); + return true; } diff --git a/version/r1.2/firmware/esp32_firmware_1-2/esp32_firmware_1-2.ino b/version/r1.2/firmware/esp32_firmware_1-2/esp32_firmware_1-2.ino index 3acc7e1..94b0e14 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/esp32_firmware_1-2.ino +++ b/version/r1.2/firmware/esp32_firmware_1-2/esp32_firmware_1-2.ino @@ -28,15 +28,23 @@ #include #include #include +#include #include #include #include "soc/soc.h" // Disable brownout problems #include "soc/rtc_cntl_reg.h" // Disable brownout problems #include "driver/rtc_io.h" +#include "esp_task_wdt.h" // Per-task watchdog for the two pinned loops #include "SPIFFS.h" #include "Base64.h" +// Task watchdog timeout. Long enough to cover HTTPS connect + send (each +// capped at 5s in api.h) plus the 3s wifiResetButton blocking poll, with +// margin. If a task fails to reset the WDT within this window the chip +// panics and reboots, so a wedged radar drain or stuck POST recovers. +#define WDT_TIMEOUT_S 30 + // --- GPIO assignments (board revision 1.2) --- #define STM32_RESET_PIN GPIO_NUM_47 // Drives STM32 NRST low to reset the radar MCU #define RX_GPIO 42 // UART1 RX from STM32 (speed reports) @@ -56,8 +64,6 @@ Preferences preferences; // NVS-backed key/value store for WiFi creds and user #include "api.h" #include "espui_settings.h" -DNSServer dnsServer; // Captive-DNS used in AP mode so any URL hits the ESPUI portal - TaskHandle_t Task0; // Handle for the Core 0 task (HTTPS uploads / WiFi reconnect) TaskHandle_t Task1; // Handle for the Core 1 task (radar polling / sleep / DNS) @@ -72,6 +78,34 @@ TaskHandle_t Task1; // Handle for the Core 1 task (radar polling / sleep / DNS) void setup() { Serial.begin(115200); + // Derive a stable per-device password from the WiFi MAC. 8 hex chars + // satisfies WPA2's 8-char minimum; same string is reused for ESPUI auth. + { + uint8_t mac[6]; + WiFi.macAddress(mac); + char buf[9]; + snprintf(buf, sizeof(buf), "%02X%02X%02X%02X", mac[2], mac[3], mac[4], mac[5]); + espui_password = String(buf); + } + Serial.print("Soft-AP / ESPUI password: "); + Serial.println(espui_password); + + // Reconfigure the task watchdog with our timeout before either pinned + // task subscribes. The init API differs between Arduino-ESP32 2.x + // (IDF 4: timeout-seconds + bool) and 3.x (IDF 5: config struct). + // deinit is safe even if the WDT was never inited. + esp_task_wdt_deinit(); +#if ESP_IDF_VERSION_MAJOR >= 5 + esp_task_wdt_config_t wdt_config = { + .timeout_ms = WDT_TIMEOUT_S * 1000, + .idle_core_mask = 0, + .trigger_panic = true, + }; + esp_task_wdt_init(&wdt_config); +#else + esp_task_wdt_init(WDT_TIMEOUT_S, true); +#endif + // GPIO setup pinMode(ESP_WAKEUP_PIN, INPUT); pinMode(WIFI_RESET_PIN, INPUT); // Set the wifi reset pin @@ -94,6 +128,10 @@ void setup() { ssid = preferences.getString("ssid", "NOT_SET"); password = preferences.getString("pass", "NOT_SET"); camera_id = preferences.getString("camera_id", "NOT_SET"); // Create an account and camera at tachtracker.com + // Default falls back to the original baked-in token so devices flashed + // before this change keep working without manual configuration; the + // operator can rotate it from the ESPUI portal afterward. + api_token = preferences.getString("api_token", "bc6d8bd23bbeb6b13fa67448c244a129"); min_speed = preferences.getInt("min_speed", 3); // The minimum speed (MPH) that the tracker should track any vehicle and upload data photo_speed = preferences.getInt("photo_speed", 10); // Cars speed (MPH) when photo should be taken is_kph = preferences.getBool("is_kph", 0); // Cars speed (MPH) when photo should be taken @@ -101,6 +139,9 @@ void setup() { // Connect CDM324 sensor Serial.println("Connecting CDM324"); Serial1.begin(1000000, SERIAL_8N1, RX_GPIO, TX_GPIO); + // Bound the parseFloat() wait in get_speed() so a missed STM32 reply + // can't stall the radar polling loop for a full second. + Serial1.setTimeout(50); Serial.setDebugOutput(false); // Reset CDM324 @@ -115,8 +156,15 @@ void setup() { // Send local IP address to API if connected to internet sendLocalIP(); - // Put device to sleep after 120 seconds after setup - sleep_time = millis() + 10000; //120000 + // Bring up ArduinoOTA so fielded firmware can be updated without USB. + // Only meaningful in STA mode (the AP fallback is for first-time setup + // before any firmware has shipped). The 120s grace window below keeps + // WiFi alive long enough to push an update right after a reboot. + setupOTA(); + + // Post-boot grace window: keep WiFi alive for 120s so the user has time + // to reach the ESPUI portal before the idle path tears the radio down. + sleep_time = millis() + 120000; wake_flag = true; // ignore device measurements for 5 seconds after startup @@ -172,10 +220,20 @@ const long interval = 5000; // Idle window (ms) of zero-speed before slee * 7. Polls the WiFi-reset button. */ void taskCore1(void* parameter) { // Code for task running on Core 1 + esp_task_wdt_add(NULL); // Subscribe this task to the watchdog + unsigned long last_status_push = 0; // ms timestamp of last ESPUI status refresh while (1) { // Loop indefinitely + esp_task_wdt_reset(); // Heartbeat the watchdog each cycle dnsServer.processNextRequest(); // Process request for ESPUI + // Push status-tab updates at ~1Hz; the radar loop runs at 10Hz which + // is too fast for the ESPUI WebSocket and would just queue updates. + if (millis() - last_status_push >= 1000) { + last_status_push = millis(); + updateStatusUI(); + } + if (ignore_flag == true) { if (millis() >= ignore_time) { ignore_flag = false; // Only if 5 seconds passed @@ -198,11 +256,18 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 if (millis() >= sleep_time) { // Only if 120 seconds passed if (digitalRead(ESP_WAKEUP_PIN) == 0) { // Only if STM not measuring data wake_flag = false; - Serial.println("Going to sleep 1"); // Go to sleep - esp_light_sleep_start(); // - WiFi.disconnect(true); // Disconnect from network, optionally true to remove credentials - WiFi.mode(WIFI_OFF); // Set Wi-Fi mode to OFF + // Only tear the radio down when we're actually associated to a + // network. In AP-fallback mode the user may still be configuring + // WiFi via the captive portal, so leave the AP up. + if (WiFi.getMode() != WIFI_AP) { + Serial.println("Grace window over: powering down WiFi"); + esp_light_sleep_start(); // Do not sleep in version 1.1 + //WiFi.disconnect(true); // wifioff=true; second arg defaults false so creds are kept + //WiFi.mode(WIFI_OFF); + } else { + Serial.println("Grace window over: keeping soft-AP up for configuration"); + } // Add power camera off to save battery // To initiate hardware power-down, the PWDN pin must be tied to high. @@ -219,11 +284,15 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 if (speed == 0) { // Check if speed is 0 if (currentMillis - previousMillis >= interval) { previousMillis = currentMillis; // Save the last time - Serial.println("Going to sleep 2"); // Go to sleep - esp_light_sleep_start(); - WiFi.disconnect(true); // Disconnect from network, optionally true to remove credentials - WiFi.mode(WIFI_OFF); // Set Wi-Fi mode to OFF + // Same AP-aware guard as the grace-window path above; keep the + // captive portal alive while the user is configuring. + if (WiFi.getMode() != WIFI_AP) { + Serial.println("Idle 5s with no radar activity: powering down WiFi"); + esp_light_sleep_start(); // Do not Go to sleep on R1.1 because USB will disconnect + //WiFi.disconnect(true); + //WiFi.mode(WIFI_OFF); + } previousMillis = millis(); // Add power camera off to save battery @@ -253,11 +322,14 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 delay(100); maxSpeed = 0; // Tracks the max speed during the entire duraton of tracking bool collect_data_point = true; + bool photo_captured = false; send_data = false; send_photo = false; speed_collection_complete = false; // Don't send data until photo is finished while (speed >= min_speed) { // Capture the cars maximum speed during the entire drive. Then reset back to zero. Send this max speed. + esp_task_wdt_reset(); // Long passes can exceed WDT_TIMEOUT_S; heartbeat per cycle + if (is_kph == true) { speed = get_speed(true); // Get speed (KPH) from STM32 via UART } else { @@ -272,9 +344,11 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 if ((maxSpeed >= photo_speed) && (collect_data_point == true)) { // Only take a photo if one is not already in progress Serial.println("Taking photo_speed photo"); - takePhoto(); - send_data = true; // Process photo on Core 0 - collect_data_point = false; // flag that activates photo only one time + photo_captured = takePhoto(); + if (!photo_captured) { + Serial.println("Capture failed; will not retry this run"); + } + collect_data_point = false; // give up on the photo for this run either way } delay(100); @@ -283,12 +357,18 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 Serial.print("MAX maxSpeed: "); Serial.println(maxSpeed); - if (maxSpeed >= photo_speed) { + // Only flag a photo upload if we actually have one. A speeding pass + // with a failed capture falls through to the speed-only endpoint. + if (maxSpeed >= photo_speed && photo_captured) { send_photo = true; } - - send_API = true; - speed_collection_complete = true; // Signal to httpsSend task to send data + // Every pass that crossed min_speed gets uploaded - photo runs hit + // the speeding endpoint, all others (under photo_speed or failed + // capture) go to the non_speeding endpoint with just the speed. + // Order matters: send_photo must be set before speed_collection_complete + // so sendPhoto() picks the right endpoint after its wait loop unblocks. + send_data = true; + speed_collection_complete = true; previousMillis = millis(); } @@ -310,7 +390,13 @@ void taskCore1(void* parameter) { // Code for task running on Core 1 * triggers a (re)connection attempt. */ void taskCore0(void* parameter) { + esp_task_wdt_add(NULL); while (1) { + esp_task_wdt_reset(); + + // Service OTA before the (potentially blocking) HTTPS upload so an + // in-progress flash isn't delayed behind a 5s POST. + ArduinoOTA.handle(); if (send_data == true) { sendPhoto(); diff --git a/version/r1.2/firmware/esp32_firmware_1-2/espui_settings.h b/version/r1.2/firmware/esp32_firmware_1-2/espui_settings.h index 1346556..a935bdd 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/espui_settings.h +++ b/version/r1.2/firmware/esp32_firmware_1-2/espui_settings.h @@ -65,6 +65,11 @@ void textCameraIdCall(Control* sender, int type) { // Leave blank } +// API token is also persisted on Save; nothing to do per keystroke. +void textApiTokenCall(Control* sender, int type) { +// Leave blank +} + // SSID is read on Save (see buttonSaveNetworkCall); no live update needed. void textNetworkCall(Control* sender, int type) { // ssid = sender->value; @@ -86,12 +91,15 @@ void textPasswordCall(Control* sender, int type) { void buttonSaveNetworkCall(Control* sender, int type) { if (type == B_UP) { Serial.println("Button Pressed"); - String ssid = ESPUI.getControl(wifi_ssid_text)->value; - String pass = ESPUI.getControl(wifi_pass_text)->value; - String camera_id = ESPUI.getControl(camera_id_text)->value; - preferences.putString("ssid", ssid); - preferences.putString("pass", pass); - preferences.putString("camera_id", camera_id); + // Renamed to avoid shadowing the globals declared in variables.h. + String new_ssid = ESPUI.getControl(wifi_ssid_text)->value; + String new_pass = ESPUI.getControl(wifi_pass_text)->value; + String new_camera_id = ESPUI.getControl(camera_id_text)->value; + String new_api_token = ESPUI.getControl(api_token_text)->value; + preferences.putString("ssid", new_ssid); + preferences.putString("pass", new_pass); + preferences.putString("camera_id", new_camera_id); + preferences.putString("api_token", new_api_token); ESP.restart(); } } @@ -112,23 +120,78 @@ void buttonClearNetworkCall(Control* sender, int type) { } } +/** + * Refresh the read-only labels on the Status tab. + * + * Called from taskCore1 on a ~1Hz cadence (slower than the radar polling + * loop so we don't flood the WebSocket connection ESPUI uses to push + * value updates to connected browsers). All values are derived from + * existing globals + the WiFi driver. + */ +void updateStatusUI(void) { + String unit = is_kph ? " kph" : " mph"; + + ESPUI.updateLabel(status_speed_label, String(speed, 1) + unit); + ESPUI.updateLabel(status_max_label, String(maxSpeed, 1) + unit); + + String upload_status; + if (sending_data) { + upload_status = "Uploading..."; + } else if (httpsResponseCode == 0) { + upload_status = "No uploads yet"; + } else if (httpsResponseCode > 0) { + upload_status = "OK (" + String(httpsResponseCode) + ")"; + } else { + upload_status = "Failed (" + String(httpsResponseCode) + ")"; + } + ESPUI.updateLabel(status_upload_label, upload_status); + + String wifi_status; + wifi_mode_t mode = WiFi.getMode(); + if (mode == WIFI_AP) { + wifi_status = "AP mode (configuration)"; + } else if (mode == WIFI_OFF) { + wifi_status = "Radio off (idle)"; + } else if (WiFi.status() == WL_CONNECTED) { + wifi_status = WiFi.SSID() + " (" + String(WiFi.RSSI()) + " dBm)"; + } else { + wifi_status = "Disconnected"; + } + ESPUI.updateLabel(status_wifi_label, wifi_status); + + unsigned long sec = millis() / 1000; + char buf[32]; + snprintf(buf, sizeof(buf), "%luh %lum %lus", sec / 3600, (sec % 3600) / 60, sec % 60); + ESPUI.updateLabel(status_uptime_label, String(buf)); +} + /** * Build the ESPUI control tree and start serving it. * - * Tab 1 ("Device"): + * Tab 1 ("Status"): live read-only telemetry, refreshed by updateStatusUI. + * + * Tab 2 ("Device"): * - MPH/KPH switcher * - Minimum speed (run threshold) * - Photo speed (capture threshold) * - * Tab 2 ("Wifi Settings"): + * Tab 3 ("Wifi Settings"): * - Clear Settings button - * - Network / Password / Camera ID text inputs + * - Network / Password / Camera ID / API Token text inputs * - Save Settings button */ void load_espui(void) { + uint16_t tab_status = ESPUI.addControl(ControlType::Tab, "Status", "Status"); uint16_t tab1 = ESPUI.addControl(ControlType::Tab, "Device", "Device"); uint16_t tab2 = ESPUI.addControl(ControlType::Tab, "Wifi Settings", "Wifi Settings"); + // tab_status: read-only telemetry, updated from taskCore1 each second. + status_speed_label = ESPUI.addControl(ControlType::Label, "Current Speed", "--", ControlColor::Turquoise, tab_status); + status_max_label = ESPUI.addControl(ControlType::Label, "Last Run Max", "--", ControlColor::Turquoise, tab_status); + status_upload_label = ESPUI.addControl(ControlType::Label, "Last Upload", "--", ControlColor::Turquoise, tab_status); + status_wifi_label = ESPUI.addControl(ControlType::Label, "WiFi", "--", ControlColor::Turquoise, tab_status); + status_uptime_label = ESPUI.addControl(ControlType::Label, "Uptime", "--", ControlColor::Turquoise, tab_status); + //tab1: Device settings ESPUI.addControl(ControlType::Switcher, "MPH/KPH:", String(is_kph), ControlColor::Alizarin, tab1, &speedUnitsCall); ESPUI.addControl(ControlType::Number, "Minimum Speed:", String(min_speed), ControlColor::Alizarin, tab1, &minSpeedCall); @@ -145,6 +208,7 @@ void load_espui(void) { wifi_ssid_text = ESPUI.addControl(ControlType::Text, "Network", String(ssid), ControlColor::Emerald, tab2, &textNetworkCall); //Text: Network wifi_pass_text = ESPUI.addControl(ControlType::Text, "Password", String(password), ControlColor::Emerald, tab2, &textPasswordCall); //Text: Password camera_id_text = ESPUI.addControl(ControlType::Text, "Camera ID:", String(camera_id), ControlColor::Peterriver, tab2, &textCameraIdCall); //Text: Camera ID + api_token_text = ESPUI.addControl(ControlType::Text, "API Token:", String(api_token), ControlColor::Peterriver, tab2, &textApiTokenCall); //Text: API token (rotatable) //Button: Save ESPUI.addControl(ControlType::Button, "Save Settings", "SAVE", ControlColor::Emerald, tab2, &buttonSaveNetworkCall); @@ -158,13 +222,8 @@ void load_espui(void) { // Enable this option if you want sliders to be continuous (update during move) and not discrete (update on stop) // ESPUI.sliderContinuous = true; - /* - * Optionally you can use HTTP BasicAuth. Keep in mind that this is NOT a - * SECURE way of limiting access. - * Anyone who is able to sniff traffic will be able to intercept your password - * since it is transmitted in cleartext. Just add a string as username and - * password, for example begin("ESPUI Control", "username", "password") - */ - - ESPUI.begin("ESPUI Control"); + // HTTP basic auth - cleartext over plain HTTP, but combined with the + // soft-AP's WPA2 PSK or the user's home-WiFi WPA2 it's a meaningful + // barrier against casual LAN-side configuration tampering. + ESPUI.begin("MiniSpeedCam", "admin", espui_password.c_str()); } \ No newline at end of file diff --git a/version/r1.2/firmware/esp32_firmware_1-2/radar.h b/version/r1.2/firmware/esp32_firmware_1-2/radar.h index 8f8229e..e841dcd 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/radar.h +++ b/version/r1.2/firmware/esp32_firmware_1-2/radar.h @@ -16,61 +16,70 @@ void issue_cdm324_reset(void); /** * Query the STM32 for the most recent speed sample. * + * Drains any leftover bytes (a previous reply we never read), issues the + * unit-specific command, then blocks in parseFloat() up to Serial1's + * configured timeout (set in setup() via Serial1.setTimeout) waiting for + * the response. Without the drain the buffer would always lag by one + * cycle since the original code checked available() before the STM32 + * had a chance to reply. + * * @param kmh true = request KPH, false = request MPH. - * @return Speed in the requested units. Returns 0 if the STM32 did - * not have data ready when polled. + * @return Speed in the requested units (one decimal of resolution), + * or 0 if the STM32 did not respond before the timeout. */ float get_speed(bool kmh) { - if (kmh == true) { - // Query km/h * 10 - Serial1.print('k'); - } else { - // Query mph * 10 - Serial1.print('m'); + // Drop any stale bytes from a previous unanswered query so parseFloat + // doesn't pick up data that belongs to an earlier command. + while (Serial1.available() > 0) { + Serial1.read(); } - float speed = 0; - if (Serial1.available() > 0) { - speed = Serial1.parseFloat(); - } else { - Serial.println("NO DATA"); - } + Serial1.print(kmh ? 'k' : 'm'); - // STM32 reports speed * 10 as an integer-style ASCII float; scale back to a real value. - return (speed / 10); + // STM32 reports speed * 10 as an ASCII float; scale back to a real value. + return Serial1.parseFloat() / 10.0f; } /** * Reset the STM32 and consume its boot banner. * * Drives STM32_RESET_PIN low for 20ms, then reads bytes off UART1 until - * a newline (or the buffer fills) so the next get_speed() starts from - * a clean RX queue. The banner is echoed on Serial for debugging. + * a newline, the buffer fills, or the timeout elapses. Bounding the wait + * matters because this runs from setup(), before the task watchdog has + * any subscribers - a silent STM32 (unprogrammed, dead, or wired wrong) + * would otherwise wedge the boot indefinitely. */ void issue_cdm324_reset() { bool string_received = false; char receive_buffer[50]; int index = 0; - // 20ms reset + // 20ms reset pulse digitalWrite(STM32_RESET_PIN, LOW); delay(20); digitalWrite(STM32_RESET_PIN, HIGH); - // get string - while (string_received == false) { + const unsigned long banner_timeout_ms = 1000; + const unsigned long start = millis(); + + while (!string_received && (millis() - start) < banner_timeout_ms) { if (Serial1.available() > 0) { char bla = Serial1.read(); if (index >= (int)sizeof(receive_buffer) - 1) { break; // Buffer full; bail out before we overflow. } receive_buffer[index++] = bla; - if (bla == '\n') + if (bla == '\n') { string_received = true; + } } } receive_buffer[index] = 0; - Serial.println("Received from the CDM324:"); - Serial.println(receive_buffer); + if (string_received) { + Serial.println("Received from the CDM324:"); + Serial.println(receive_buffer); + } else { + Serial.println("Timed out waiting for CDM324 banner; continuing anyway"); + } } \ No newline at end of file diff --git a/version/r1.2/firmware/esp32_firmware_1-2/variables.h b/version/r1.2/firmware/esp32_firmware_1-2/variables.h index 50e7623..a85e29d 100644 --- a/version/r1.2/firmware/esp32_firmware_1-2/variables.h +++ b/version/r1.2/firmware/esp32_firmware_1-2/variables.h @@ -8,28 +8,25 @@ */ // --- Live measurement / control state --- -int speed; // Latest speed sample read from the STM32 (MPH or KPH per is_kph) -bool API_photo; // (reserved) photo-via-API flag +float speed = 0.0f; // Latest speed sample read from the STM32 (MPH or KPH per is_kph) bool send_data; // Core 1 -> Core 0: a fresh photo is captured, please upload it int min_speed; // Minimum speed (configured units) that begins a tracking run int photo_speed; // Speed threshold within a run that triggers a photo capture bool connect_wifi; // Core 1 -> Core 0: please attempt a WiFi (re)connect -bool send_API; // (reserved) generic API-send flag -bool photo_finished; // (reserved) photo-capture-complete flag bool speed_collection_complete = false; // Core 1 sets true once the per-vehicle max speed is finalized bool is_kph; // true = KPH units, false = MPH (persisted in NVS) bool send_photo; // true = upload included a photo (vehicle was speeding) -int maxSpeed; // Highest speed observed during the current tracking run +float maxSpeed = 0.0f; // Highest speed observed during the current tracking run (0.1 resolution from STM32) // --- WiFi / cloud credentials (persisted in NVS) --- String ssid; // Stored WiFi SSID, "NOT_SET" until configured String password; // Stored WiFi password, "NOT_SET" until configured String camera_id; // minispeedcam.com camera identifier +String api_token; // Bearer token for the minispeedcam.com Bubble.io workflows; rotatable from the ESPUI portal // --- Sleep / startup gating --- unsigned long sleep_time; // Absolute millis() at which post-boot grace window ends bool wake_flag; // true while the post-boot grace window is active -unsigned long sleep_idle_time; // (reserved) idle-sleep timestamp bool ignore_flag; // true: drop measurements during the startup blanking window unsigned long ignore_time; // Absolute millis() at which the blanking window ends @@ -41,34 +38,26 @@ const char* server_speeding = "https://minispeedcam.com/api/1.1/wf/speeding_capt const char* server_non_speeding = "https://minispeedcam.com/api/1.1/wf/non_speeding_capture"; // POST: max speed only const char* server_local_ip_address = "https://minispeedcam.com/api/1.1/wf/local_ip_address"; // POST: announce local IP -// the following variables are unsigned longs because the time, measured in -// milliseconds, will quickly become a bigger number than can be stored in an int. -unsigned long lastTime = 0; - // --- HTTP scratch buffers --- String payload; // Last HTTPS response body (debug) int httpsResponseCode; // Last HTTPS status code -String httpsRequestData; // Reusable request-body buffer (reserved up to ~150KB for photo POSTs) +String httpsRequestData; // Reusable request-body buffer used by sendLocalIP() String photo_base64; // Base64-encoded JPEG produced by takePhoto() String photo_filename; // Filename field included in the upload payload int speed_actual; // Snapshot of maxSpeed taken at upload time -bool send_alert; // (reserved) alert/notification flag - // --- ESPUI control handles --- -uint16_t labelWifi; // ESPUI label showing current WiFi status -uint16_t wifi_ssid_text, wifi_pass_text, camera_id_text; // ESPUI text inputs for credentials -String display_wifi; // Cached string used to update the WiFi label +uint16_t wifi_ssid_text, wifi_pass_text, camera_id_text, api_token_text; // ESPUI text inputs for credentials +uint16_t status_speed_label, status_max_label, status_upload_label, status_wifi_label, status_uptime_label; // Live status tab labels String local_ip_address; // Most recent station-mode IP (sent to the cloud) -const byte DNS_PORT = 53; // Captive DNS port for AP-mode redirection -IPAddress apIP(192, 168, 4, 1); // Soft-AP gateway / portal IP - String hostname = "Radar"; // mDNS / WiFi station hostname -// --- Misc ESPUI handles --- -uint16_t button1; -uint16_t switchOne; -uint16_t status; \ No newline at end of file +DNSServer dnsServer; // Captive DNS used in AP mode so any URL hits the ESPUI portal + +// Per-device password derived from the WiFi MAC. Used both as the soft-AP +// WPA2 PSK and as the ESPUI HTTP basic-auth password (user "admin"). +// Printed to Serial at boot; the operator gets it from the device label. +String espui_password; \ No newline at end of file