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