diff --git a/.github/workflows/build_and_release.yml b/.github/workflows/build_and_release.yml
index b06bc8a..6afe03e 100644
--- a/.github/workflows/build_and_release.yml
+++ b/.github/workflows/build_and_release.yml
@@ -60,6 +60,6 @@ jobs:
uses: ncipollo/release-action@v1
with:
allowUpdates: true
- artifacts: "firmware/firmware*.bin"
+ artifacts: "firmware/*.bin"
bodyFile: "CHANGELOG.md"
removeArtifacts: true
diff --git a/.github/workflows/build_firmware.yml b/.github/workflows/build_firmware.yml
index 5c50504..6951f18 100644
--- a/.github/workflows/build_firmware.yml
+++ b/.github/workflows/build_firmware.yml
@@ -41,19 +41,21 @@ jobs:
python-version: "3.x"
- name: Install PlatformIO
run: pip install platformio
+ - name: Build file system image for ${{ inputs.platform }}
+ run: pio run --target buildfs -e ${{ inputs.platform }}
- name: Build for ${{ inputs.platform }}
run: pio run -e ${{ inputs.platform }} -t mergebin
#- name: Display firmware files
# run: ls -la .pio/build/${{ inputs.platform }}/firmware*.bin
- name: Rename and move firmware files
run: |
+ ls -la .pio/build/${{ inputs.platform }}/*.bin
mv .pio/build/${{ inputs.platform }}/firmware.bin firmware_${{ inputs.platform }}_ota.bin
- if [ "${{ inputs.platform }}" != "esp8266dev" ]; then
- mv .pio/build/${{ inputs.platform }}/firmware_factory.bin firmware_${{ inputs.platform }}_factory.bin
- fi
- ls -la firmware*.bin
+ mv .pio/build/${{ inputs.platform }}/firmware_factory.bin firmware_${{ inputs.platform }}_factory.bin
+ mv .pio/build/${{ inputs.platform }}/spiffs.bin spiffs_${{ inputs.platform }}.bin
+ ls -la *.bin
- name: Archive Firmware
uses: actions/upload-artifact@v4
with:
name: firmware_${{ inputs.platform }}
- path: firmware_${{ inputs.platform }}*.bin
+ path: "*_${{ inputs.platform }}*.bin"
diff --git a/.gitignore b/.gitignore
index beaaf8a..8293886 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
.vscode/launch.json
.vscode/ipch
.vscode/extensions.json
+.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85d8476..7daed85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,6 @@
-**Added support for multi-speed pumps**
+**New Web UI**
-**Breaking change**
-* This changes the pumps to type fan, but does not delete the old pump switches. You will need to manually delete them with MQTT explorer or disable them in HA.
-* This will break any pump automations you have.
+***Breaking change*** *You should deploy v1.0.9 if migrating from an ealier version, this will ensure your settings (mqtt server, spa name, etc) are migrated.*
-**Added mDNS support (default name espa.local)
-
-**Added support for SV3-VH (aka Craig's Spa)
\ No newline at end of file
+* Changed settings to be stored in Preferences
+* New Web UI
\ No newline at end of file
diff --git a/README.md b/README.md
index 8dbc1e8..dac6661 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,7 @@
-# SpaNet MQTT ESP32 bridge
+# eSpa
-SpaNet serial to mqtt bridge, including HomeAssitant autodiscovery.
+## Introduction
-Developed for the ESP32 Dev board but should work on any ESP32 platform. By default uses UART2 for communications with the SpaNet controller.
-
-Discussion on this (and other) SpaNet projects can be found here https://discord.com/channels/967940763595980900/967940763595980903
-
-## Configuration
-On first boot or whenever the enable key is press the board will enter hotspot mode. Connect to the hotspot to configure wifi & mqtt settings.
-
-## Firmware updates
-
-Firmware updates can be pushed to http://ipaddress/fota
-
-## Logging
-
-Debug / log functionality is available by telneting to the device's ip address
-
-
-## Circuit
-To keep things as simple as possible, off the shelf modules have been used.
-NOTE: The resistors on the RX/TX pins are recommended but optional.
-
-
-
-
-
-
+The [eSpa project](https://espa.diy) is an open source community for spa home automation, built around [firmware](https://espa.diy/firmware.html) (found in this GitHub repo) and a simple [hardware design](https://espa.diy/hardware.html) that you can build yourself, or [purchase pre-assembled](https://store.espa.diy/).
+Learn more at the [eSpa website](https://espa.diy), and [join us on Discord](https://discord.gg/faK8Ag4wHn).
diff --git a/data/www/espa.js b/data/www/espa.js
new file mode 100644
index 0000000..edc804c
--- /dev/null
+++ b/data/www/espa.js
@@ -0,0 +1,537 @@
+/************************************************************************************************
+ *
+ * Utility Methods
+ *
+ ***********************************************************************************************/
+function confirmAction(url) {
+ if (confirm('Are you sure?')) {
+ window.location.href = url;
+ }
+}
+
+function confirmFunction(func) {
+ if (confirm('Are you sure?')) {
+ func();
+ }
+}
+
+function parseVersion(version) {
+ const match = version.match(/^v([\d.]+)/);
+ if (!match) return null;
+ return match[1].split('.').map(Number);
+}
+
+function compareVersions(current, latest) {
+ if (!current) return -1;
+ if (!latest) return 1;
+ for (let i = 0; i < Math.max(current.length, latest.length); i++) {
+ const a = current[i] || 0;
+ const b = latest[i] || 0;
+ if (a < b) return -1;
+ if (a > b) return 1;
+ }
+ return 0;
+}
+
+// Copy to clipboard functionality
+$('#copyToClipboardButton').click(function () {
+ copyToClipboard('#infoModelPre');
+});
+
+function copyToClipboard(element) {
+ const text = $(element).text();
+ navigator.clipboard.writeText(text).then(() => {
+ //alert('Copied to clipboard: ' + text);
+ }).catch(err => {
+ console.error('Failed to copy: ', err);
+ });
+}
+
+function reboot(message) {
+ $.ajax({
+ url: '/reboot',
+ type: 'GET',
+ success: () => showAlert(message, 'alert-success', 'Reboot'),
+ error: () => showAlert('Failed to initiate reboot.', 'alert-danger', 'Error'),
+ complete: () => setTimeout(() => location.href = '/', 2000)
+ });
+}
+
+
+/************************************************************************************************
+ *
+ * Status
+ *
+ ***********************************************************************************************/
+
+let fetchStatusFailed = false;
+
+function fetchStatus() {
+ fetch('/json')
+ .then(response => response.json())
+ .then(value_json => {
+ if (fetchStatusFailed) {
+ clearAlert();
+ fetchStatusFailed = false;
+ }
+ updateStatusElement('status_state', value_json.status.state);
+ updateStatusElement('temperatures_water', value_json.temperatures.water + "\u00B0C");
+ updateStatusElement('temperatures_setPoint', value_json.temperatures.setPoint);
+ updateStatusElement('status_controller', value_json.status.controller);
+ updateStatusElement('status_firmware', value_json.status.firmware);
+ updateStatusElement('status_serial', value_json.status.serial);
+ updateStatusElement('status_siInitialised', value_json.status.siInitialised);
+ updateStatusElement('status_mqtt', value_json.status.mqtt);
+ updateStatusElement('espa_model', value_json.eSpa.model);
+ updateStatusElement('espa_build', value_json.eSpa.update.installed_version);
+ })
+ .catch(error => {
+ console.error('Error fetching status:', error);
+ showAlert('Error connecting to the spa. If this persists, take a look at our troubleshooting docs.', 'alert-danger', "Error");
+ fetchStatusFailed = true;
+ handleStatusError('status_state');
+ handleStatusError('temperatures_water');
+ handleStatusError('temperatures_setPoint');
+ handleStatusError('status_controller');
+ handleStatusError('status_firmware');
+ handleStatusError('status_serial');
+ handleStatusError('status_siInitialised');
+ handleStatusError('status_mqtt');
+ handleStatusError('espa_model');
+ handleStatusError('espa_build');
+ });
+}
+
+function updateStatusElement(elementId, value) {
+ const element = document.getElementById(elementId);
+ element.classList.remove('badge', 'text-bg-warning', 'text-bg-danger');
+ if (element instanceof HTMLInputElement) {
+ element.value = value;
+ } else {
+ element.textContent = value;
+ }
+}
+
+function handleStatusError(elementId) {
+ const element = document.getElementById(elementId);
+ element.classList.remove('text-bg-warning');
+ element.classList.add('text-bg-danger');
+ if (element instanceof HTMLInputElement) {
+ element.value = '';
+ } else {
+ element.textContent = 'Failed to load';
+ }
+}
+
+function clearAlert() {
+ const pageAlert = $('#page-alert');
+ const pageAlertParent = $('.page-alert-parent');
+ pageAlert.removeClass(function (index, className) {
+ return (className.match(/(^|\s)alert-\S+/g) || []).join(' ');
+ }).text('');
+ pageAlertParent.hide();
+}
+
+window.onload = function () {
+ fetchStatus();
+ loadFotaData();
+ setInterval(fetchStatus, 10000);
+}
+
+
+/************************************************************************************************
+ *
+ * Updating eSpa configuration
+ *
+ ***********************************************************************************************/
+
+function updateTempSetPoint() {
+ const temperatures_setPoint = document.getElementById('temperatures_setPoint').value;
+ fetch('/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'temperatures_setPoint=' + temperatures_setPoint
+ })
+ .then(response => response.text())
+ .then(result => console.log(result))
+ .catch(error => console.error('Error setting temperature:', error));
+}
+
+function sendCurrentTime() {
+ const status_datetime = new Date(Date() + " UTC").toISOString().slice(0, 19).replace("T", " ");
+ fetch('/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'status_datetime=' + status_datetime
+ })
+ .then(response => response.text())
+ .then(result => console.log(result))
+ .catch(error => console.error('Error setting datetime:', error));
+}
+
+// Retrieving and updating the configured settings, so they can be displayed in the modal popup
+function loadConfig() {
+ $('#configErrorAlert').hide();
+ fetch('/json/config')
+ .then(response => response.json())
+ .then(data => {
+ document.getElementById('spaName').value = data.spaName;
+ document.getElementById('mqttServer').value = data.mqttServer;
+ document.getElementById('mqttPort').value = data.mqttPort;
+ document.getElementById('mqttUsername').value = data.mqttUsername;
+ document.getElementById('mqttPassword').value = data.mqttPassword;
+ document.getElementById('updateFrequency').value = data.updateFrequency;
+
+ // Enable form fields and save button
+ $('#config_form input').prop('disabled', false);
+ $('#saveConfigButton').prop('disabled', false);
+ })
+ .catch(error => {
+ console.error('Error loading config:', error);
+ $('#configErrorAlert').text('Error loading configuration. Please try again.').show();
+
+ // Make form fields read-only and disable save button
+ $('#config_form input').prop('disabled', true);
+ $('#saveConfigButton').prop('disabled', true);
+ });
+}
+
+// Configuration modal
+$(document).ready(function () {
+ // configuration settings modal
+ $('#configLink').click(function (event) {
+ event.preventDefault();
+ $('#configModal').modal('show');
+ });
+
+ // Load configuration when the config modal is shown
+ $('#configModal').on('shown.bs.modal', function () {
+ loadConfig();
+ });
+
+ // Handle form submission when the save button is clicked
+ $('#saveConfigButton').click(function () {
+ submitConfigForm();
+ });
+
+ function submitConfigForm() {
+ $.ajax({
+ url: '/config',
+ type: 'POST',
+ data: $('#config_form').serialize(),
+ success: function () {
+ showAlert('Configuration updated successfully!', 'alert-success', 'Success');
+ loadConfig();
+ $('#configModal').modal('hide');
+ },
+ error: function () {
+ $('#configErrorAlert').text('Error updating configuration. Please try again.').show();
+ }
+ });
+ }
+
+ $('#config_form').submit(function (e) {
+ e.preventDefault();
+ submitConfigForm();
+ });
+});
+
+
+/************************************************************************************************
+ *
+ * OTA Update Support
+ *
+ ***********************************************************************************************/
+
+$(document).ready(function () {
+ $('#progressDiv').hide();
+ $('#localInstallButton').prop('disabled', true);
+ $('#localUpdate').show();
+ document.getElementById('updateForm').reset();
+
+ // Delegate event listener for dynamically added #fotaLink
+ $(document).on('click', '#fotaLink', function (event) {
+ event.preventDefault();
+ $('#fotaModal').modal('show');
+ // loadFotaData();
+ });
+
+ // Enable the local install button when a file is selected
+ $('#fsFile').change(updateLocalInstallButton);
+ $('#appFile').change(updateLocalInstallButton);
+ function updateLocalInstallButton () {
+ if ($('#fsFile').val() || $('#appFile').val()) {
+ $('#localInstallButton').prop('disabled', false);
+ } else {
+ $('#localInstallButton').prop('disabled', true);
+ }
+ };
+
+ // Handle local install button click
+ $('#localInstallButton').click(async function () {
+ const appFile = $('#appFile')[0].files[0];
+ const fsFile = $('#fsFile')[0].files[0];
+ let appSuccess = false, fsSuccess = false;
+
+ if (!appFile && !fsFile) {
+ showAlert('Please select either an application or filesystem update file.', 'alert-danger', 'Error');
+ console.error('No files selected for upload.');
+ return;
+ }
+
+ let totalFiles = 1;
+ if (appFile && fsFile) totalFiles = 2;
+ let fileNum = 0;
+ // Upload application file if provided
+ if (appFile) {
+ const appData = new FormData();
+ appData.append('updateType', 'application');
+ appData.append('update', appFile);
+ fileNum++;
+ $('#msg').html(`
Uploading file ${fileNum} of ${totalFiles} - Application update.
`);
+ appSuccess = await uploadFileAsync(appData, '/fota', fileNum, totalFiles);
+ }
+
+ // Upload filesystem file if provided
+ if (fsFile) {
+ const fsData = new FormData();
+ fsData.append('updateType', 'filesystem');
+ fsData.append('update', fsFile);
+ fileNum++;
+ $('#msg').html(`Uploading file ${fileNum} of ${totalFiles} - File system update.
`);
+ fsSuccess = await uploadFileAsync(fsData, '/fota', fileNum, totalFiles);
+ }
+
+ // Trigger reboot only if all provided uploads were successful
+ if ((!appFile || appSuccess) && (!fsFile || fsSuccess)) {
+ $('#fotaModal').modal('hide');
+ setTimeout(() => reboot('The firmware has been updated successfully. The spa will now restart to apply the changes.'), 500);
+ } else {
+ showAlert('One or more uploads failed.', 'alert-danger', 'Error');
+ }
+
+ document.getElementById('updateForm').reset();
+ });
+
+ async function uploadFileAsync(data, url, fileNum, totalFiles) {
+ let percentMultipler = 1 / totalFiles;
+ let startPercent = percentMultipler * 100 * (fileNum - 1);
+ return new Promise((resolve) => {
+ $.ajax({
+ url,
+ type: 'POST',
+ data,
+ contentType: false,
+ processData: false,
+ xhr: function () {
+ $('#progressDiv').show();
+ const xhr = new XMLHttpRequest();
+ xhr.upload.addEventListener('progress', function(evt) {
+ if (evt.lengthComputable) {
+ var percentComplete = evt.loaded / evt.total;
+ percentComplete = parseInt((percentComplete * 100 * percentMultipler) + startPercent);
+ $('#progressBar').css('width', percentComplete + '%').attr('aria-valuenow', percentComplete).text(percentComplete + '%');
+ }
+ }, false);
+ return xhr;
+ },
+ success: function (data) {
+ showAlert('The firmware has been uploaded.', 'alert-success', 'Firmware uploaded');
+ resolve(true);
+ },
+ error: function () {
+ showAlert('The firmware update failed. Please try again.', 'alert-danger', 'Error');
+ resolve(false);
+ }
+ });
+ });
+ }
+}
+
+ // Handle remote update installation
+ /*
+ $('#remoteInstallButton').click(function (event) {
+ event.preventDefault();
+ var selectedVersion = $('#firmware-select').val();
+ if (selectedVersion) {
+ $.ajax({
+ url: '/install',
+ type: 'POST',
+ data: { version: selectedVersion },
+ success: function (data) {
+ showAlert('The firmware has been updated successfully. The spa will now restart to apply the changes.', 'alert-success', 'Firmware updated');
+ $('#fotaModal').modal('hide');
+ },
+ error: function () {
+ showAlert('The firmware update failed. Please try again.', 'alert-danger', 'Error');
+ }
+ });
+ }
+ });
+
+ // Show/hide update sections based on selected update method
+ $('#updateMethod').change(function () {
+ var selectedMethod = $(this).val();
+ if (selectedMethod === 'remote') {
+ $('#remoteUpdate').show();
+ $('#localUpdate').hide();
+ } else if (selectedMethod === 'local') {
+ $('#remoteUpdate').hide();
+ $('#localUpdate').show();
+ } else {
+ $('#remoteUpdate').hide();
+ $('#localUpdate').hide();
+ }
+ });
+ */
+);
+
+function loadFotaData() {
+ fetch('/json')
+ .then(response => response.json())
+ .then(value_json => {
+ document.getElementById('espa_model').innerText = value_json.eSpa.model;
+ document.getElementById('installedVersion').innerText = value_json.eSpa.update.installed_version;
+ })
+ .catch(error => console.error('Error fetching FOTA data:', error));
+
+ $.ajax({
+ url: 'https://api.github.com/repos/wayne-love/ESPySpa/releases',
+ type: 'GET',
+ success: function (data) {
+ document.getElementById('lastestRelease').innerText = data[0].tag_name;
+
+ // Populate the select dropdown with all releases
+ const firmwareSelect = document.getElementById('firmware-select');
+ firmwareSelect.innerHTML = ''; // Clear existing options
+
+ // Add default disabled option
+ const defaultOption = document.createElement('option');
+ defaultOption.value = '';
+ defaultOption.text = 'Select a version';
+ defaultOption.disabled = true;
+ defaultOption.selected = true;
+ firmwareSelect.appendChild(defaultOption);
+
+ // Add release options
+ data.forEach(release => {
+ const option = document.createElement('option');
+ option.value = release.tag_name;
+ option.text = release.tag_name;
+ firmwareSelect.appendChild(option);
+ });
+
+ // Enable the install button when a valid choice is selected
+ firmwareSelect.addEventListener('change', function () {
+ if (firmwareSelect.value) {
+ $('#remoteInstallButton').prop('disabled', false);
+ } else {
+ $('#remoteInstallButton').prop('disabled', true);
+ }
+ });
+
+ // Check for new version
+ const latestVersion = parseVersion(data[0].tag_name);
+ const currentVersion = parseVersion(document.getElementById('installedVersion').innerText);
+ const comparison = compareVersions(currentVersion, latestVersion);
+ if (comparison < 0) {
+ showAlert(`There is a new eSpa release available - it's version ${data[0].tag_name}. You can update now.`, 'alert-primary', "New eSpa release!");
+ }
+ },
+ error: function () {
+ showAlert('Failed to fetch eSpa release information. If this persists, take a look at our troubleshooting docs.', 'alert-danger', "Error");
+ }
+ });
+}
+
+
+/************************************************************************************************
+ *
+ * Status models (for JSON and Spa response)
+ *
+ ***********************************************************************************************/
+
+$(document).ready(function () {
+ // JSON dump modal
+ $('#jsonLink').click(function (event) {
+ event.preventDefault();
+ fetch('/json').then(response => response.json()).then(data => {
+ $('#infoModalTitle').html("Spa JSON");
+ $('#infoModalBody').html('' + JSON.stringify(data, null, 2) + '
');
+ $('#infoModal').modal('show');
+ })
+ .catch(error => {
+ console.error('Error fetching JSON:', error);
+ showAlert('Error connecting to the spa. If this persists, take a look at our troubleshooting docs.', 'alert-danger', "Error");
+ });
+ });
+
+ // spa status modal
+ $('#statusLink').click(function (event) {
+ event.preventDefault();
+ fetch('/status').then(response => response.text()).then(data => {
+ $('#infoModalTitle').html("Spa Status");
+ $('#infoModalBody').html('' + data + '
');
+ $('#infoModal').modal('show');
+ })
+ .catch(error => {
+ console.error('Error fetching status:', error);
+ showAlert('Error connecting to the spa. If this persists, take a look at our troubleshooting docs.', 'alert-danger', "Error");
+ });
+ });
+});
+
+
+/************************************************************************************************
+ *
+ * Front page alerts
+ *
+ ***********************************************************************************************/
+
+function showAlert(message, alertClass, title = '') {
+ const pageAlert = $('#page-alert');
+ const pageAlertParent = $('.page-alert-parent');
+
+ // Clear existing alert classes and set the new class
+ pageAlert.removeClass(function (index, className) {
+ return (className.match(/(^|\s)alert-\S+/g) || []).join(' ');
+ }).addClass(alertClass);
+
+ // Construct the alert content
+ let alertContent = '';
+ if (title) {
+ alertContent += `${title}
`;
+ }
+ alertContent += message;
+
+ // Set the alert content and show the alert
+ pageAlert.html(alertContent);
+ pageAlertParent.show();
+}
+
+
+/************************************************************************************************
+ *
+ * Light / Dark Mode Switch
+ *
+ ***********************************************************************************************/
+
+document.addEventListener('DOMContentLoaded', (event) => {
+ const htmlElement = document.documentElement;
+ const switchElement = document.getElementById('darkModeSwitch');
+
+ // Set the default theme to dark if no setting is found in local storage
+ const currentTheme = localStorage.getItem('bsTheme') || 'dark';
+ htmlElement.setAttribute('data-bs-theme', currentTheme);
+ switchElement.checked = currentTheme === 'dark';
+
+ switchElement.addEventListener('change', function () {
+ if (this.checked) {
+ htmlElement.setAttribute('data-bs-theme', 'dark');
+ localStorage.setItem('bsTheme', 'dark');
+ } else {
+ htmlElement.setAttribute('data-bs-theme', 'light');
+ localStorage.setItem('bsTheme', 'light');
+ }
+ });
+});
\ No newline at end of file
diff --git a/data/www/favicon.ico b/data/www/favicon.ico
new file mode 100644
index 0000000..a4b5997
Binary files /dev/null and b/data/www/favicon.ico differ
diff --git a/data/www/images/heart.svg b/data/www/images/heart.svg
new file mode 100644
index 0000000..43bda91
--- /dev/null
+++ b/data/www/images/heart.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/data/www/images/logo_eSpa_64px.png b/data/www/images/logo_eSpa_64px.png
new file mode 100644
index 0000000..5f674fa
Binary files /dev/null and b/data/www/images/logo_eSpa_64px.png differ
diff --git a/data/www/index.htm b/data/www/index.htm
new file mode 100644
index 0000000..d59ab91
--- /dev/null
+++ b/data/www/index.htm
@@ -0,0 +1,308 @@
+
+
+
+
+
+
+
+
+
+
+ eSpa
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Spa status: |
+ Loading... |
+
+
+ | Spa temperature: |
+ Loading... |
+
+
+ | Spa controller: |
+ Loading... |
+
+
+ | Spa controller firmware: |
+ Loading...
+ |
+
+
+ | Spa serial number: |
+ Loading... |
+
+
+ | Spa interface initialised: |
+ Loading... |
+
+
+ | MQTT status: |
+ Loading... |
+
+
+ | eSpa Model: |
+ Loading... |
+
+
+ | eSpa Build: |
+ Loading... |
+
+
+
+
+
+
+
+
+
+
+ Built with
by the eSpa Team
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error loading configuration. Please try again.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/data/www/styles.css b/data/www/styles.css
new file mode 100644
index 0000000..239a853
--- /dev/null
+++ b/data/www/styles.css
@@ -0,0 +1,72 @@
+/* input[type=file]::file-selector-button, input[type="submit"], a, button {
+ padding: 7px 15px;
+ border: none;
+ background: #007BFF;
+ color: white;
+ text-decoration: none;
+ border-radius: 5px;
+ margin-top: 5px;
+ display: inline-block;
+ font-size: 16px;
+ font-family: Arial, sans-serif;
+ cursor: pointer;
+ text-align: center;
+}
+input[type=file]::file-selector-button:hover, input[type="submit"]:hover, a:hover, button:hover {
+ background-color: #0056b3;
+}
+table, td, th {
+ border: 1px solid;
+ padding: 5px;
+}
+table {
+ border-collapse: collapse;
+} */
+
+h1, h2, h3 {
+ text-align: left;
+ font-weight: 300;
+ padding-top: 20px;
+ display: flex;
+ align-items: center;
+}
+
+.footer {
+ font-size: 10px;
+ text-align: center;
+}
+
+/* .navbar-dark .navbar-nav .nav-link {
+ color: white;
+} */
+
+/* html[data-bs-theme="light"] .dropdown-menu {
+ background-color: white;
+} */
+/*
+html[data-bs-theme="dark"] .dropdown-menu {
+ background-color: #0069d9;
+} */
+
+.modal-content {
+ border: none;
+}
+
+/* .modal-header {
+ background-color: #0069d9;
+ color: white;
+} */
+
+.modal-header .modal-title {
+ font-weight: 400;
+}
+
+/* .modal-header .close {
+ color: white;
+} */
+
+input[type="submit"]:disabled, button:disabled {
+ background: #cccccc;
+ color: #666666;
+ cursor: not-allowed;
+}
diff --git a/lib/Config/Config.cpp b/lib/Config/Config.cpp
index 98103ac..e35bfe8 100644
--- a/lib/Config/Config.cpp
+++ b/lib/Config/Config.cpp
@@ -4,60 +4,46 @@ template
void (*Setting::_settingCallback)(const char*, T) = nullptr;
void (*Setting::_settingCallback)(const char*, int) = nullptr;
+Preferences preferences;
+
// Constructor
Config::Config() { }
-// Read config from file
-bool Config::readConfigFile() {
- debugI("Reading config file");
- File configFile = LittleFS.open("/config.json","r");
- if (!configFile) {
- return false;
+// Read configuration
+bool Config::readConfig() {
+ debugI("Reading config from Preferences or file");
+
+ // Check if Preferences are available
+ if (preferences.begin("eSpa-config", true)) {
+ debugI("Using Preferences for configuration");
+
+ MqttServer.setValue(preferences.getString("MqttServer", ""));
+ MqttPort.setValue(preferences.getInt("MqttPort", 1883));
+ MqttUsername.setValue(preferences.getString("MqttUsername", ""));
+ MqttPassword.setValue(preferences.getString("MqttPassword", ""));
+ SpaName.setValue(preferences.getString("SpaName", "eSpa"));
+ UpdateFrequency.setValue(preferences.getInt("spaPollFreq", 60));
+
+ preferences.end();
+ return true;
} else {
- size_t size = configFile.size();
- std::unique_ptr buf(new char[size]);
- configFile.readBytes(buf.get(), size);
-
- JsonDocument json;
- auto deserializeError = deserializeJson(json, buf.get());
- serializeJson(json, Serial);
-
- if (!deserializeError) {
- debugI("Parsed JSON");
-
- if (json["mqtt_server"].is()) MqttServer.setValue(json["mqtt_server"].as());
- if (json["mqtt_port"].is()) MqttPort.setValue(json["mqtt_port"].as());
- if (json["mqtt_username"].is()) MqttUsername.setValue(json["mqtt_username"].as());
- if (json["mqtt_password"].is()) MqttPassword.setValue(json["mqtt_password"].as());
- if (json["spa_name"].is()) SpaName.setValue(json["spa_name"].as());
- if (json["update_frequency"].is()) UpdateFrequency.setValue(json["update_frequency"].as());
- } else {
- debugW("Failed to parse config file");
- }
- configFile.close();
+ debugI("Preferences not found.");
}
-
- return true;
+ return false;
}
-// Write config to file
-void Config::writeConfigFile() {
- debugI("Updating config file");
- JsonDocument json;
-
- json["mqtt_server"] = MqttServer.getValue();
- json["mqtt_port"] = MqttPort.getValue();
- json["mqtt_username"] = MqttUsername.getValue();
- json["mqtt_password"] = MqttPassword.getValue();
- json["spa_name"] = SpaName.getValue();
- json["update_frequency"] = UpdateFrequency.getValue();
-
- File configFile = LittleFS.open("/config.json", "w");
- if (!configFile) {
- debugE("Failed to open config file for writing");
+// Write configuration to Preferences
+void Config::writeConfig() {
+ debugI("Writing configuration to Preferences");
+ if (preferences.begin("eSpa-config", false)) {
+ preferences.putString("MqttServer", MqttServer.getValue());
+ preferences.putInt("MqttPort", MqttPort.getValue());
+ preferences.putString("MqttUsername", MqttUsername.getValue());
+ preferences.putString("MqttPassword", MqttPassword.getValue());
+ preferences.putString("SpaName", SpaName.getValue());
+ preferences.putInt("spaPollFreq", UpdateFrequency.getValue());
+ preferences.end();
} else {
- serializeJson(json, configFile);
- configFile.close();
- debugI("Config file updated");
+ debugE("Failed to open Preferences for writing");
}
}
diff --git a/lib/Config/Config.h b/lib/Config/Config.h
index be70d31..c7f7538 100644
--- a/lib/Config/Config.h
+++ b/lib/Config/Config.h
@@ -1,10 +1,10 @@
#ifndef CONFIG_H
#define CONFIG_H
+#include
#include
#include
#include
-#include
extern RemoteDebug Debug;
@@ -77,15 +77,15 @@ class Config : public ControllerConfig {
// Constructor
Config();
- // File operations
- bool readConfigFile();
- void writeConfigFile();
+ bool readConfig(); // Read configuration from Preferences or file
+ void writeConfig(); // Write configuration to Preferences
// Set callback for all Setting instances
template
void setCallback(void (*callback)(const char*, T)) {
Setting::setCallback(callback);
}
+
};
#endif // CONFIG_H
diff --git a/lib/MultiBlinker/MultiBlinker.cpp b/lib/MultiBlinker/MultiBlinker.cpp
index 084e89a..e2fe3bf 100644
--- a/lib/MultiBlinker/MultiBlinker.cpp
+++ b/lib/MultiBlinker/MultiBlinker.cpp
@@ -2,23 +2,23 @@
// Define the on/off times for each state (-1 to 15)
const LEDPattern LED_PATTERNS[17] = {
- {2000, 2000}, //KNIGHT_RIDER
- {UINT_MAX, 0}, // STATE_NONE: Always off
- {100, 100}, // STATE_WIFI_NOT_CONNECTED
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {500, 500}, // STATE_MQTT_NOT_CONNECTED
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, 0}, // Reserved
- {0, UINT_MAX} // STATE_STARTED_WIFI_AP: Always on
+ {2000, 2000}, //KNIGHT_RIDER
+ {UINT_MAX, 0}, // STATE_NONE: Always off
+ {100, 100}, // STATE_WIFI_NOT_CONNECTED
+ {1000, 1000}, // STATE_WAITING_FOR_SPA
+ {0, 0}, // Reserved
+ {500, 500}, // STATE_MQTT_NOT_CONNECTED
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, 0}, // Reserved
+ {0, UINT_MAX} // STATE_STARTED_WIFI_AP: Always on
};
MultiBlinker::MultiBlinker(int led1, int led2, int led3, int led4) {
diff --git a/lib/MultiBlinker/MultiBlinker.h b/lib/MultiBlinker/MultiBlinker.h
index bf93774..56710e8 100644
--- a/lib/MultiBlinker/MultiBlinker.h
+++ b/lib/MultiBlinker/MultiBlinker.h
@@ -19,8 +19,9 @@ extern RemoteDebug Debug;
const int KNIGHT_RIDER = -1; // Knight Rider animation or 2000ms blink
const int STATE_NONE = 0; // ON: (nothing)
const int STATE_STARTED_WIFI_AP = 15; // ON: ALL or solid on
-const int STATE_WIFI_NOT_CONNECTED = 1; // ON: 4 or 100ms blink
-const int STATE_MQTT_NOT_CONNECTED = 4; // ON: 2 or 500ms blink
+const int STATE_WIFI_NOT_CONNECTED = 1; // ON: LED 4 or 100ms blink
+const int STATE_WAITING_FOR_SPA = 2; // ON: LED 3 or 1000ms blink
+const int STATE_MQTT_NOT_CONNECTED = 4; // ON: LED 2 or 500ms blink
const int MULTI_BLINKER_INTERVAL = 100;
diff --git a/lib/SpaUtils/SpaUtils.cpp b/lib/SpaUtils/SpaUtils.cpp
index fd479ab..4117ee4 100644
--- a/lib/SpaUtils/SpaUtils.cpp
+++ b/lib/SpaUtils/SpaUtils.cpp
@@ -146,10 +146,16 @@ bool generateStatusJson(SpaInterface &si, MQTTClientWrapper &mqttClient, String
json["status"]["state"] = si.getStatus();
json["status"]["spaMode"] = si.getMode();
json["status"]["controller"] = si.getModel();
+ String firmware = si.getSVER().substring(3);
+ firmware.replace(' ', '.');
+ json["status"]["firmware"] = firmware;
json["status"]["serial"] = si.getSerialNo1() + "-" + si.getSerialNo2();
json["status"]["siInitialised"] = si.isInitialised()?"true":"false";
json["status"]["mqtt"] = mqttClient.connected()?"connected":"disconnected";
+ json["eSpa"]["model"] = xstr(PIOENV);
+ json["eSpa"]["update"]["installed_version"] = xstr(BUILD_INFO);
+
json["heatpump"]["mode"] = si.HPMPStrings[si.getHPMP()];
json["heatpump"]["auxheat"] = si.getHELE()==0? "OFF" : "ON";
diff --git a/lib/SpaUtils/SpaUtils.h b/lib/SpaUtils/SpaUtils.h
index 39bb8a0..24fcac9 100644
--- a/lib/SpaUtils/SpaUtils.h
+++ b/lib/SpaUtils/SpaUtils.h
@@ -11,6 +11,10 @@
#include
#include "MQTTClientWrapper.h"
+//define stringify function
+#define xstr(a) str(a)
+#define str(a) #a
+
extern RemoteDebug Debug;
String convertToTime(int data);
diff --git a/lib/WebUI/WebUI.cpp b/lib/WebUI/WebUI.cpp
index 754b208..d460efd 100644
--- a/lib/WebUI/WebUI.cpp
+++ b/lib/WebUI/WebUI.cpp
@@ -14,105 +14,91 @@ const char * WebUI::getError() {
return Update.errorString();
}
-
void WebUI::begin() {
-
- server.reset(new WebServer(80));
-
- server->on("/", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/html", WebUI::indexPageTemplate);
- });
-
- server->on("/json", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- String json;
- if (generateStatusJson(*_spa, *_mqttClient, json, true)) {
- server->send(200, "text/json", json.c_str());
- } else {
- server->send(200, "text/text", "Error generating json");
- }
- });
-
- server->on("/reboot", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->send(200, "text/html", WebUI::rebootPage);
+ server.on("/reboot", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ request->send(200, "text/plain", "Rebooting ESP...");
debugD("Rebooting...");
delay(200);
- server->client().stop();
ESP.restart();
});
- server->on("/styles.css", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->send(200, "text/css", WebUI::styleSheet);
+ server.on("/fota", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ request->send(200, "text/html", fotaPage);
});
- server->on("/fota", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/html", WebUI::fotaPage);
+ server.on("/config", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ request->send(SPIFFS, "/www/config.htm");
});
- server->on("/fota", HTTP_POST, [&]() {
- debugD("uri: %s", server->uri().c_str());
+ server.on("/fota", HTTP_POST, [this](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
if (Update.hasError()) {
- server->sendHeader("Connection", "close");
- server->send(200, F("text/plain"), String(F("Update error: ")) + String(getError()));
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", String("Update error: ") + String(this->getError()));
+ response->addHeader("Connection", "close");
+ request->send(response);
} else {
- server->client().setNoDelay(true);
- server->sendHeader("Connection", "close");
- server->send(200, "text/plain", "OK");
- debugD("Rebooting...");
- delay(100);
- server->client().stop();
- ESP.restart();
+ request->client()->setNoDelay(true);
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "OK");
+ response->addHeader("Connection", "close");
+ request->send(response);
}
- }, [&]() {
- debugD("uri: %s", server->uri().c_str());
- HTTPUpload& upload = server->upload();
- if (upload.status == UPLOAD_FILE_START) {
- debugD("Update: %s", upload.filename.c_str());
- if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { //start with max available size
- debugD("Update Error: %s",getError());
+ }, [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
+ if (index == 0) {
+ static int updateType = U_FLASH; // Default to firmware update
+
+ if (request->hasArg("updateType")) {
+ String type = request->arg("updateType");
+ if (type == "filesystem") {
+ updateType = U_SPIFFS;
+ debugD("Filesystem update selected.");
+ } else if (type == "application") {
+ updateType = U_FLASH;
+ debugD("Application (firmware) update selected.");
+ } else {
+ debugD("Unknown update type: %s", type.c_str());
+ //server->send(400, "text/plain", "Invalid update type");
+ //return;
+ }
+ } else {
+ debugD("No update type specified. Defaulting to application update.");
}
- } else if (upload.status == UPLOAD_FILE_WRITE) {
- /* flashing firmware to ESP*/
- if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
- debugD("Update Error: %s",getError());
+
+ debugD("Update: %s", filename.c_str());
+ if (!Update.begin(UPDATE_SIZE_UNKNOWN, updateType)) { // start with max available size
+ debugD("Update Error: %s", this->getError());
}
- } else if (upload.status == UPLOAD_FILE_END) {
- if (Update.end(true)) { //true to set the size to the current progress
- debugD("Update Success: %u\n", upload.totalSize);
+ }
+ if (Update.write(data, len) != len) {
+ debugD("Update Error: %s", this->getError());
+ }
+ if (final) {
+ if (Update.end(true)) { // true to set the size to the current progress
+ debugD("Update Success: %u\n", index + len);
} else {
- debugD("Update Error: %s",getError());
+ debugD("Update Error: %s", this->getError());
}
}
});
- server->on("/config", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/html", WebUI::configPageTemplate);
- });
-
- server->on("/config", HTTP_POST, [&]() {
- debugD("uri: %s", server->uri().c_str());
- if (server->hasArg("spaName")) _config->SpaName.setValue(server->arg("spaName"));
- if (server->hasArg("mqttServer")) _config->MqttServer.setValue(server->arg("mqttServer"));
- if (server->hasArg("mqttPort")) _config->MqttPort.setValue(server->arg("mqttPort").toInt());
- if (server->hasArg("mqttUsername")) _config->MqttUsername.setValue(server->arg("mqttUsername"));
- if (server->hasArg("mqttPassword")) _config->MqttPassword.setValue(server->arg("mqttPassword"));
- if (server->hasArg("updateFrequency")) _config->UpdateFrequency.setValue(server->arg("updateFrequency").toInt());
- _config->writeConfigFile();
- server->sendHeader("Connection", "close");
- server->send(200, "text/plain", "Updated");
+ server.on("/config", HTTP_POST, [this](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ if (request->hasParam("spaName", true)) _config->SpaName.setValue(request->getParam("spaName", true)->value());
+ if (request->hasParam("mqttServer", true)) _config->MqttServer.setValue(request->getParam("mqttServer", true)->value());
+ if (request->hasParam("mqttPort", true)) _config->MqttPort.setValue(request->getParam("mqttPort", true)->value().toInt());
+ if (request->hasParam("mqttUsername", true)) _config->MqttUsername.setValue(request->getParam("mqttUsername", true)->value());
+ if (request->hasParam("mqttPassword", true)) _config->MqttPassword.setValue(request->getParam("mqttPassword", true)->value());
+ if (request->hasParam("updateFrequency", true)) _config->UpdateFrequency.setValue(request->getParam("updateFrequency", true)->value().toInt());
+ _config->writeConfig();
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "Updated");
+ response->addHeader("Connection", "close");
+ request->send(response);
});
- server->on("/json/config", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
+ server.on("/json/config", HTTP_GET, [this](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
String configJson = "{";
configJson += "\"spaName\":\"" + _config->SpaName.getValue() + "\",";
configJson += "\"mqttServer\":\"" + _config->MqttServer.getValue() + "\",";
@@ -121,54 +107,74 @@ void WebUI::begin() {
configJson += "\"mqttPassword\":\"" + _config->MqttPassword.getValue() + "\",";
configJson += "\"updateFrequency\":" + String(_config->UpdateFrequency.getValue());
configJson += "}";
- server->send(200, "application/json", configJson);
+ AsyncWebServerResponse *response = request->beginResponse(200, "application/json", configJson);
+ response->addHeader("Connection", "close");
+ request->send(response);
});
- server->on("/set", HTTP_POST, [&]() {
- //In theory with minor modification, we can reuse mqttCallback here
- //for (uint8_t i = 0; i < server->args(); i++) updateSpaSetting("set/" + server->argName(0), server->arg(0));
- if (server->hasArg("temperatures_setPoint")) {
- float newTemperature = server->arg("temperatures_setPoint").toFloat();
- _spa->setSTMP(int(newTemperature*10));
- server->send(200, "text/plain", "Temperature updated");
+ server.on("/json", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ String json;
+ AsyncWebServerResponse *response;
+ if (generateStatusJson(*_spa, *_mqttClient, json, true)) {
+ response = request->beginResponse(200, "application/json", json);
+ } else {
+ response = request->beginResponse(200, "text/plain", "Error generating json");
}
- else if (server->hasArg("status_datetime")) {
- String p = server->arg("status_datetime");
+ response->addHeader("Connection", "close");
+ request->send(response);
+ });
+
+ // Handle /set endpoint (POST)
+ server.on("/set", HTTP_POST, [this](AsyncWebServerRequest *request) {
+ // In theory with minor modification, we can reuse mqttCallback here
+ // for (uint8_t i = 0; i < request->params(); i++) updateSpaSetting("set/" + request->getParam(i)->name(), request->getParam(i)->value());
+ if (request->hasParam("temperatures_setPoint", true)) {
+ float newTemperature = request->getParam("temperatures_setPoint", true)->value().toFloat();
+ _spa->setSTMP(int(newTemperature * 10));
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "Temperature updated");
+ response->addHeader("Connection", "close");
+ request->send(response);
+ } else if (request->hasParam("status_datetime", true)) {
+ String p = request->getParam("status_datetime", true)->value();
tmElements_t tm;
- tm.Year=CalendarYrToTm(p.substring(0,4).toInt());
- tm.Month=p.substring(5,7).toInt();
- tm.Day=p.substring(8,10).toInt();
- tm.Hour=p.substring(11,13).toInt();
- tm.Minute=p.substring(14,16).toInt();
- tm.Second=p.substring(17).toInt();
+ tm.Year = CalendarYrToTm(p.substring(0, 4).toInt());
+ tm.Month = p.substring(5, 7).toInt();
+ tm.Day = p.substring(8, 10).toInt();
+ tm.Hour = p.substring(11, 13).toInt();
+ tm.Minute = p.substring(14, 16).toInt();
+ tm.Second = p.substring(17).toInt();
_spa->setSpaTime(makeTime(tm));
- server->send(200, "text/plain", "Date/Time updated");
- }
- else {
- server->send(400, "text/plain", "Invalid temperature value");
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "Date/Time updated");
+ response->addHeader("Connection", "close");
+ request->send(response);
+ } else {
+ AsyncWebServerResponse *response = request->beginResponse(400, "text/plain", "Invalid temperature value");
+ response->addHeader("Connection", "close");
+ request->send(response);
}
});
- server->on("/wifi-manager", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/plain", "WiFi Manager launching, connect to ESP WiFi...");
+ // Handle /wifi-manager endpoint (GET)
+ server.on("/wifi-manager", HTTP_GET, [this](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "WiFi Manager launching, connect to ESP WiFi...");
+ response->addHeader("Connection", "close");
+ request->send(response);
if (_wifiManagerCallback != nullptr) { _wifiManagerCallback(); }
});
- server->on("/json.html", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/html", WebUI::jsonHTMLTemplate);
+ server.on("/status", HTTP_GET, [this](AsyncWebServerRequest *request) {
+ debugD("uri: %s", request->url().c_str());
+ AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", _spa->statusResponse.getValue());
+ response->addHeader("Connection", "close");
+ request->send(response);
});
- server->on("/status", HTTP_GET, [&]() {
- debugD("uri: %s", server->uri().c_str());
- server->sendHeader("Connection", "close");
- server->send(200, "text/plain", _spa->statusResponse.getValue());
- });
+ // As a fallback we try to load from /www any requested URL
+ server.serveStatic("/", SPIFFS, "/www/");
- server->begin();
+ server.begin();
initialised = true;
}
\ No newline at end of file
diff --git a/lib/WebUI/WebUI.h b/lib/WebUI/WebUI.h
index d2a5105..51db774 100644
--- a/lib/WebUI/WebUI.h
+++ b/lib/WebUI/WebUI.h
@@ -3,23 +3,19 @@
#include
-#include
+#include "ESPAsyncWebServer.h"
#include
+#include
#include "SpaInterface.h"
#include "SpaUtils.h"
#include "Config.h"
#include "MQTTClientWrapper.h"
-//define stringify function
-#define xstr(a) str(a)
-#define str(a) #a
-
extern RemoteDebug Debug;
class WebUI {
public:
- std::unique_ptr server;
WebUI(SpaInterface *spa, Config *config, MQTTClientWrapper *mqttClient);
/// @brief Set the function to be called when properties have been updated.
@@ -29,6 +25,7 @@ class WebUI {
bool initialised = false;
private:
+ AsyncWebServer server{80};
SpaInterface *_spa;
Config *_config;
MQTTClientWrapper *_mqttClient;
@@ -36,379 +33,120 @@ class WebUI {
const char* getError();
-static constexpr const char *indexPageTemplate PROGMEM =
-R"(
+ // hard-coded FOTA page in case file system gets wiped
+ static constexpr const char *fotaPage PROGMEM = R"(
+
-
+
-
-
-
-
-ESP32 Spa Controller
-
-Spa JSON HTML
-Spa JSON
-Spa Response
-Send Current Time to Spa
-Configuration
-Firmware Update
-Wi-Fi Manager
-Reboot ESP
-
-)";
-
-static constexpr const char *fotaPage PROGMEM =
-R"(
-
-
-
-
-
Firmware Update
Firmware Update
-
-
-progress: 0%
-
-
-
-)";
-
-static constexpr const char *styleSheet PROGMEM =
-R"(
-input[type=file]::file-selector-button, input[type="submit"], a, button {
- padding: 7px 15px;
- border: none;
- background: #007BFF;
- color: white;
- text-decoration: none;
- border-radius: 5px;
- margin-top: 5px;
- display: inline-block;
- font-size: 16px;
- font-family: Arial, sans-serif;
- cursor: pointer;
- text-align: center;
-}
-input[type=file]::file-selector-button:hover, input[type="submit"]:hover, a:hover, button:hover {
- background-color: #0056b3;
-}
-table, td, th {
- border: 1px solid;
- padding: 5px;
-}
-table {
- border-collapse: collapse;
-})";
-
-static constexpr const char *rebootPage PROGMEM =
-R"(
-
-
-
-
-
-
-Rebooting
-
-
-Rebooting ESP...
-
-)";
-
-static constexpr const char *configPageTemplate PROGMEM =
-R"(
-
-
-
-
-
-Configuration
-
-
-Configuration
-
-
-
+progress: 0%
+
-
-)";
-static constexpr const char *jsonHTMLTemplate PROGMEM =
-R"(
-
-
-
-
-
-
-JSON Data Table
-
-
-JSON Data Table
-
-
-)";
-
+