diff --git a/RotaxMonitor/data/index.html b/RotaxMonitor/data/index.html index 656686d..e1bc07c 100644 --- a/RotaxMonitor/data/index.html +++ b/RotaxMonitor/data/index.html @@ -1,16 +1,23 @@ + - ESP32 Dashboard + Astro Rotax Monitor + + -

RotaxMonitor realtime data

- - - +
+ Waiting for data... +

Timestamp: -

@@ -24,8 +31,8 @@ Property - Coils 12 - Coils 34 + Pickup 12 + Pickup 34 @@ -70,12 +77,12 @@ - - Events + Spark Events - - - Missed firings + Missed Events - - @@ -83,6 +90,15 @@
+
+

Upload file to Flash

+

Select a file and upload it to Flash.

+ + +
No file uploaded yet.
+
+ + \ No newline at end of file diff --git a/RotaxMonitor/data/logo_astro_dev.svg b/RotaxMonitor/data/logo_astro_dev.svg new file mode 100644 index 0000000..f504444 --- /dev/null +++ b/RotaxMonitor/data/logo_astro_dev.svg @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RotaxMonitor/data/script.js b/RotaxMonitor/data/script.js index db58a84..779c224 100644 --- a/RotaxMonitor/data/script.js +++ b/RotaxMonitor/data/script.js @@ -1,14 +1,35 @@ let ws; +let lastMessageTimestamp = 0; +const IDLE_THRESHOLD_MS = 1000; +const loadingIndicator = document.getElementById("loadingIndicator"); + +function setLoadingIndicator(visible) { + if (!loadingIndicator) { + return; + } + + loadingIndicator.classList.toggle("hidden", !visible); +} + +function updateLoadingState() { + const isConnected = ws && ws.readyState === WebSocket.OPEN; + const idle = Date.now() - lastMessageTimestamp >= IDLE_THRESHOLD_MS; + + setLoadingIndicator(isConnected && idle); +} function connectWS() { ws = new WebSocket("ws://" + location.host + "/ws"); ws.onopen = () => { console.log("WebSocket connesso"); + lastMessageTimestamp = Date.now(); + setLoadingIndicator(false); }; ws.onclose = () => { console.log("WebSocket disconnesso, retry..."); + setLoadingIndicator(false); setTimeout(connectWS, 5000); }; @@ -22,6 +43,9 @@ function connectWS() { return; } + lastMessageTimestamp = Date.now(); + setLoadingIndicator(false); + document.getElementById("datavalid").textContent = data.datavalid ?? "-"; document.getElementById("timestamp").textContent = data.timestamp ?? "-"; document.getElementById("volts_gen").textContent = data.volts_gen ?? "-"; @@ -63,4 +87,39 @@ function stop() { fetch("/stop"); } +function uploadLittleFS() { + const fileInput = document.getElementById("littlefsFile"); + const status = document.getElementById("uploadStatus"); + + if (!fileInput || fileInput.files.length === 0) { + if (status) status.textContent = "Select a file first."; + return; + } + + const file = fileInput.files[0]; + const formData = new FormData(); + formData.append("file", file, file.name); + + if (status) status.textContent = "Uploading..."; + + fetch("/upload", { + method: "POST", + body: formData, + }) + .then((resp) => { + if (!resp.ok) { + throw new Error("Upload failed: " + resp.status + " " + resp.statusText); + } + return resp.text(); + }) + .then(() => { + if (status) status.textContent = "Uploaded: " + file.name; + fileInput.value = ""; + }) + .catch((err) => { + if (status) status.textContent = err.message; + }); +} + +setInterval(updateLoadingState, 200); connectWS(); diff --git a/RotaxMonitor/data/style.css b/RotaxMonitor/data/style.css index ea261fa..cdf3297 100644 --- a/RotaxMonitor/data/style.css +++ b/RotaxMonitor/data/style.css @@ -1,7 +1,46 @@ +:root { + --primary-dark: #0a1929; + --primary-blue: #0144a8; + --accent-blue: #1e88e5; + --light-bg: #f5f7fa; + --border-color: #d0d6dd; + --text-dark: #1a1a1a; + --text-muted: #666666; +} + body { - font-family: Arial; - text-align: center; - margin-top: 40px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + margin: 0; + padding: 0; + background-color: var(--light-bg); + color: var(--text-dark); +} + +.page-header { + background: linear-gradient(135deg, var(--primary-dark) 0%, #1a3a52 100%); + color: white; + padding: 30px 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 30px; +} + +.header-content { + max-width: 900px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 20px; +} + +.logo { + height: 50px; + width: auto; +} + +.page-header h1 { + margin: 0; + font-size: 28px; + font-weight: 600; } table { @@ -9,21 +48,136 @@ table { border-collapse: collapse; width: 100%; max-width: 900px; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + border-radius: 6px; + overflow: hidden; } th, td { - border: 1px solid #ccc; - padding: 10px; - font-size: 16px; - text-align: left; + border: 1px solid var(--border-color); + padding: 12px; + font-size: 14px; + text-align: center; } th { - background-color: #f4f4f4; + background-color: var(--primary-blue); + color: white; + font-weight: 600; +} + +tr:hover { + background-color: #f9fbfc; } button { margin: 10px; padding: 10px 20px; font-size: 16px; + background-color: var(--primary-blue); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover { + background-color: var(--accent-blue); +} + +.upload-section { + margin: 30px auto 20px; + max-width: 900px; + text-align: left; + padding: 20px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.upload-section h3 { + margin-top: 0; + margin-bottom: 8px; + color: var(--primary-blue); + font-size: 16px; +} + +.upload-section p { + margin: 8px 0; + color: var(--text-muted); + font-size: 14px; +} + +.upload-section input[type="file"] { + margin-top: 8px; + margin-bottom: 12px; +} + +.upload-status { + margin-top: 10px; + font-size: 14px; + color: var(--text-muted); +} + +.loading-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 0; + padding: 16px 20px; + font-size: 20px; + color: var(--primary-blue); + border-bottom: 1px solid var(--border-color); + background: white; + width: 100%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.loading-indicator.hidden { + display: none; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: var(--primary-blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Data section */ +div[style*="max-width: 900px"] { + background: white; + padding: 20px; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin-bottom: 20px; +} + +div[style*="max-width: 900px"] p { + margin: 8px 0; + font-size: 14px; +} + +div[style*="max-width: 900px"] strong { + color: var(--primary-blue); +} + +span { + color: var(--text-dark); +} } diff --git a/RotaxMonitor/partitions/default_16MB.csv b/RotaxMonitor/partitions/default_16MB.csv deleted file mode 100644 index de523c3..0000000 --- a/RotaxMonitor/partitions/default_16MB.csv +++ /dev/null @@ -1,6 +0,0 @@ -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x5000, -otadata, data, ota, 0xe000, 0x2000, -app0, app, ota_0, 0x10000, 0x700000, -app1, app, ota_1, 0x710000,0x700000, -spiffs, data, spiffs, 0xE10000,0x1F0000, diff --git a/RotaxMonitor/platformio.ini b/RotaxMonitor/platformio.ini index e84e71b..4a09e62 100644 --- a/RotaxMonitor/platformio.ini +++ b/RotaxMonitor/platformio.ini @@ -21,13 +21,13 @@ lib_deps = me-no-dev/AsyncTCP@^3.3.2 me-no-dev/ESPAsyncWebServer@^3.6.0 upload_protocol = esptool -upload_port = COM4 +upload_port = COM8 upload_speed = 921600 monitor_port = COM4 monitor_speed = 921600 build_type = release build_flags = - -DCORE_DEBUG_LEVEL=5 + -DCORE_DEBUG_LEVEL=1 -DARDUINO_USB_CDC_ON_BOOT=0 -DARDUINO_USB_MODE=0 -DCONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=1 @@ -59,7 +59,7 @@ build_flags = -O0 -g3 -ggdb3 - -DCORE_DEBUG_LEVEL=5 + -DCORE_DEBUG_LEVEL=3 -DARDUINO_USB_CDC_ON_BOOT=0 -DARDUINO_USB_MODE=0 -DCONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=1 diff --git a/RotaxMonitor/src/datasave.cpp b/RotaxMonitor/src/datasave.cpp index da3e9f5..49b9c81 100644 --- a/RotaxMonitor/src/datasave.cpp +++ b/RotaxMonitor/src/datasave.cpp @@ -1,7 +1,26 @@ #include "datasave.h" #include -static const size_t min_free = 1024 * 1024; // minimum free space in SPIFFS to allow saving history (1MB) +static const size_t min_free = 1024 * 1024; // minimum free space in LittleFS to allow saving history (1MB) + +LITTLEFSGuard::LITTLEFSGuard() +{ + if (!LittleFS.begin(true, "/littlefs", 10, "littlefs")) + { + LOG_ERROR("Failed to mount LittleFS"); + } + else + { + LOG_INFO("LittleFS mounted successfully"); + LOG_INFO("LittleFS Free KBytes:", (LittleFS.totalBytes() - LittleFS.usedBytes()) /1024); + } +} + +LITTLEFSGuard::~LITTLEFSGuard() +{ + LittleFS.end(); + LOG_INFO("LittleFS unmounted successfully"); +} void ignitionBoxStatusAverage::filter(int32_t &old, const int32_t value, const uint32_t k) { @@ -42,18 +61,18 @@ void ignitionBoxStatusAverage::update(const ignitionBoxStatus &new_status) filter(m_last.coils12.peak_p_out, new_status.coils12.peak_p_out, m_max_count); // incremental average calculation filter(m_last.coils12.peak_n_out, new_status.coils12.peak_n_out, m_max_count); // incremental average calculation - m_last.coils34.n_events = new_status.coils34.n_events; // sum events instead of averaging - m_last.coils34.n_missed_firing = new_status.coils34.n_missed_firing; // sum missed firings instead of averaging - m_last.coils34.spark_status = new_status.coils34.spark_status; // take latest spark status - m_last.coils34.sstart_status = new_status.coils34.sstart_status; // take latest soft start status - filter(m_last.coils34.spark_delay, new_status.coils34.spark_delay, m_max_count); // incremental average calculation + m_last.coils34.n_events = new_status.coils34.n_events; // sum events instead of averaging + m_last.coils34.n_missed_firing = new_status.coils34.n_missed_firing; // sum missed firings instead of averaging + m_last.coils34.spark_status = new_status.coils34.spark_status; // take latest spark status + m_last.coils34.sstart_status = new_status.coils34.sstart_status; // take latest soft start status + filter(m_last.coils34.spark_delay, new_status.coils34.spark_delay, m_max_count); // incremental average calculation filter(m_last.coils34.peak_p_in, new_status.coils34.peak_p_in, m_max_count); // incremental average calculation filter(m_last.coils34.peak_n_in, new_status.coils34.peak_n_in, m_max_count); // incremental average calculation filter(m_last.coils34.peak_p_out, new_status.coils34.peak_p_out, m_max_count); // incremental average calculation filter(m_last.coils34.peak_n_out, new_status.coils34.peak_n_out, m_max_count); // incremental average calculation - filter(m_last.eng_rpm, new_status.eng_rpm, m_max_count); // incremental average calculation // incremental average calculation - filter(m_last.adc_read_time, m_last.adc_read_time, m_max_count); // incremental average calculation - m_last.n_queue_errors = new_status.n_queue_errors; // take last of queue errors since it's a cumulative count of errors in the queue, not an average value + filter(m_last.eng_rpm, new_status.eng_rpm, m_max_count); // incremental average calculation // incremental average calculation + filter(m_last.adc_read_time, m_last.adc_read_time, m_max_count); // incremental average calculation + m_last.n_queue_errors = new_status.n_queue_errors; // take last of queue errors since it's a cumulative count of errors in the queue, not an average value if (m_count >= m_max_count) { @@ -126,7 +145,8 @@ void save_history(const PSRAMVector &history, const std::file // Initialize SPIFFS if (!SAVE_HISTORY_TO_LITTLEFS) return; - // auto spiffs_guard = LITTLEFSGuard(); // use RAII guard to ensure SPIFFS is properly mounted and unmounted + + auto littlefs_guard = LITTLEFSGuard(); // use RAII guard to ensure LittleFS is properly mounted and unmounted if (LittleFS.totalBytes() - LittleFS.usedBytes() < min_free) // check if at least 1MB is free for saving history { @@ -142,7 +162,7 @@ void save_history(const PSRAMVector &history, const std::file if (first_save && LittleFS.exists(file_path.c_str())) { first_save = false; - save_flags |= std::ios::trunc; // overwrite existing file + save_flags |= std::ios::trunc; // overwrite existing file LittleFS.remove(file_path.c_str()); // ensure file is removed before saving to avoid issues with appending to existing file in SPIFFS LOG_INFO("Saving history to LittleFS, new file:", file_path.c_str()); } diff --git a/RotaxMonitor/src/datasave.h b/RotaxMonitor/src/datasave.h index ca54a0a..d834308 100644 --- a/RotaxMonitor/src/datasave.h +++ b/RotaxMonitor/src/datasave.h @@ -4,19 +4,19 @@ // System Includes #include #include -#include #include #include -#include #include +#include +#include // Project Includes #include "isr.h" #include "psvector.h" const uint32_t max_history = 256; -const bool SAVE_HISTORY_TO_LITTLEFS = false; // Set to true to enable saving history to SPIFFS, false to disable -static bool first_save = true; // flag to indicate if this is the first save (to write header) +const bool SAVE_HISTORY_TO_LITTLEFS = false; // Set to true to enable saving history to LittleFS, false to disable +static bool first_save = true; // flag to indicate if this is the first save (to write header) struct dataSaveParams { @@ -27,20 +27,8 @@ struct dataSaveParams class LITTLEFSGuard { public: - LITTLEFSGuard() - { - if (!LittleFS.begin(true)) - { - LOG_ERROR("Failed to mount LittleFS"); - } - LOG_INFO("SPIFFS mounted successfully"); - } - - ~LITTLEFSGuard() - { - LittleFS.end(); - LOG_INFO("LittleFS unmounted successfully"); - } + LITTLEFSGuard(); + ~LITTLEFSGuard(); }; class ignitionBoxStatusAverage @@ -53,7 +41,8 @@ private: public: ignitionBoxStatusAverage() = default; - ignitionBoxStatusAverage(const uint32_t max_count) : m_max_count(max_count) { + ignitionBoxStatusAverage(const uint32_t max_count) : m_max_count(max_count) + { m_data_valid = false; m_count = 0; } diff --git a/RotaxMonitor/src/main.cpp b/RotaxMonitor/src/main.cpp index 42c910a..ed85363 100644 --- a/RotaxMonitor/src/main.cpp +++ b/RotaxMonitor/src/main.cpp @@ -1,4 +1,4 @@ -#define DEBUGLOG_DEFAULT_LOG_LEVEL_INFO +#define DEBUGLOG_DEFAULT_LOG_LEVEL_DEBUG // Arduino Libraries #include @@ -16,6 +16,9 @@ #include #include +static File uploadFile; +static bool uploadFailed = false; + // FreeRTOS directives #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -61,11 +64,6 @@ void setup() LOG_DEBUG("ESP32 Heap:", ESP.getHeapSize()); LOG_DEBUG("ESP32 Sketch:", ESP.getFreeSketchSpace()); - // Initialize Interrupt pins on PICKUP detectors - initTriggerPinsInputs(); - // Initialize Interrupt pins on SPARK detectors - initSparkPinInputs(); - // Init Wifi station LOG_INFO("Initializing WiFi..."); WiFi.mode(WIFI_AP); @@ -87,6 +85,11 @@ void setup() vTaskDelay(pdMS_TO_TICKS(5000)); esp_restart(); } + + // Initialize Interrupt pins on PICKUP detectors + initTriggerPinsInputs(); + // Initialize Interrupt pins on SPARK detectors + initSparkPinInputs(); } void loop() @@ -123,7 +126,15 @@ void loop() .spark_pin_34 = SPARK_PIN_A34}, .rt_resets = rtTaskResets{.rst_io_12p = RST_EXT_A12P, .rst_io_12n = RST_EXT_A12N, .rst_io_34p = RST_EXT_A34P, .rst_io_34n = RST_EXT_A34N}}; - LOG_DEBUG("Task Variables OK"); + if (!rt_taskA_queue || !rt_taskB_queue) + { + LOG_ERROR("Unable To Create task queues"); + LOG_ERROR("5 seconds to restart..."); + vTaskDelay(pdMS_TO_TICKS(5000)); + esp_restart(); + } + else + LOG_DEBUG("Task Variables OK"); #ifdef CH_B_ENABLE QueueHandle_t rt_taskB_queue = xQueueCreate(max_queue, sizeof(ignitionBoxStatus)); @@ -190,6 +201,7 @@ void loop() RT_TASK_PRIORITY, &trigA_TaskHandle, CORE_0); + delay(100); // Ignition B on Core 1 auto ignB_task_success = pdPASS; @@ -202,11 +214,12 @@ void loop() RT_TASK_PRIORITY, // priorità leggermente più alta &trigB_TaskHandle, CORE_1); + delay(100); #endif - if ((ignA_task_success && ignB_task_success) != pdPASS) + if (ignA_task_success != pdPASS || ignB_task_success != pdPASS) { - LOG_ERROR("Una ble to initialize ISR task"); + LOG_ERROR("Unable to initialize ISR task"); LOG_ERROR("5 seconds to restart..."); vTaskDelay(pdMS_TO_TICKS(5000)); esp_restart(); @@ -219,7 +232,8 @@ void loop() uint32_t counter = 0; uint32_t wait_count = 0; ignitionBoxStatus ign_info; - ignitionBoxStatusAverage ign_info_avg(filter_k); + ignitionBoxStatusAverage ign_info_avg(filter_k); + LITTLEFSGuard fsGuard; // Initialize Web page AsyncWebServer server(80); @@ -227,6 +241,67 @@ void loop() ws.onEvent(onWsEvent); server.addHandler(&ws); server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + + server.on("/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + if (uploadFailed) + { + request->send(500, "text/plain", "Upload failed"); + } + else + { + request->send(200, "text/plain", "Upload successful"); + } + }, + [](AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) { + if (index == 0) + { + uploadFailed = false; + String safeName = filename; + int slashIndex = safeName.lastIndexOf('/'); + if (slashIndex >= 0) + { + safeName = safeName.substring(slashIndex + 1); + } + if (safeName.length() == 0) + { + uploadFailed = true; + return; + } + + String filePath = "/" + safeName; + if (LittleFS.exists(filePath)) + { + LittleFS.remove(filePath); + } + + uploadFile = LittleFS.open(filePath, FILE_WRITE); + if (!uploadFile) + { + uploadFailed = true; + LOG_ERROR("Failed to open upload file:", filePath); + return; + } + } + + if (!uploadFailed && uploadFile) + { + if (uploadFile.write(data, len) != len) + { + uploadFailed = true; + } + } + + if (final && uploadFile) + { + uploadFile.close(); + if (!uploadFailed) + { + LOG_INFO("Uploaded file to LittleFS:", filename); + } + } + }); + server.begin(); server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) @@ -253,12 +328,12 @@ void loop() auto &hist = *active_history; hist[counter++ % active_history->size()] = ign_info; ign_info_avg.update(ign_info); // update moving average with latest ignition status - Serial.print("Data Received: " + String(counter) + "/" + String(hist.size()) + '\r'); + Serial.print("\033[2K Data Received: " + String(counter) + "/" + String(hist.size()) + '\r'); - if (ws.count() > 0 && counter % 10 == 0) // send data every 10 samples + if (ws.count() > 0 && counter % filter_k == 0) // send data every 10 samples { Serial.println(); - LOG_INFO("Sending average ignition status to websocket clients..."); + LOG_DEBUG("Sending average ignition status to websocket clients..."); auto msg = ign_info_avg.toJson().as(); ws.textAll(msg); } diff --git a/RotaxMonitor/src/tasks.cpp b/RotaxMonitor/src/tasks.cpp index 86b9681..45a9aa0 100644 --- a/RotaxMonitor/src/tasks.cpp +++ b/RotaxMonitor/src/tasks.cpp @@ -110,7 +110,6 @@ void rtIgnitionTask(void *pvParameters) #ifdef DEBUG Serial.print("\033[2J"); // clear screen Serial.print("\033[H"); // cursor home - LOG_INFO("Iteration [", it++, "]"); if (!names.contains(pickup_flag)) { @@ -267,9 +266,11 @@ void rtIgnitionTask(void *pvParameters) // send essage to main loop with ignition info, by copy so local static variable is ok if (rt_queue) + { ign_box_sts.timestamp = esp_timer_get_time(); // update data timestamp - if (xQueueSendToBack(rt_queue, (void *)&ign_box_sts, 0) != pdPASS) - ign_box_sts.n_queue_errors = ++n_errors; + if (xQueueSendToBack(rt_queue, (void *)&ign_box_sts, 0) != pdPASS) + ign_box_sts.n_queue_errors = ++n_errors; + } } } // Delete the timeout timer diff --git a/RotaxMonitor/src/webserver.cpp b/RotaxMonitor/src/webserver.cpp new file mode 100644 index 0000000..e69de29 diff --git a/RotaxMonitor/src/webserver.h b/RotaxMonitor/src/webserver.h new file mode 100644 index 0000000..e69de29