diff --git a/.gitignore b/.gitignore index 5762142..8e36ca5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/c_cpp_properties.json .vscode/launch.json .vscode/ipch +dist/ \ No newline at end of file diff --git a/README.md b/README.md index 605fafe..8440bf9 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,9 @@ Modes: | `cool` | Runs the pump when measured temperature is above the target | | `warm` | Runs the pump when measured temperature is below the target | -The target temperature is stored in Celsius and supports up to two decimals. The firmware compares the measured temperature and target temperature at two-decimal precision. In `cool` mode, the pump runs when the measured temperature is at least 0.25 C above the target. In `warm` mode, the pump runs when the measured temperature is at least 0.25 C below the target. +The target temperature and threshold are stored in Celsius and support up to two decimals. The firmware compares the measured temperature, target temperature, and threshold at two-decimal precision. The threshold acts as the start offset only. In `cool` mode, the pump starts when the measured temperature is at least the threshold above the target and then stops only when the measured temperature reaches the target again. In `warm` mode, the pump starts when the measured temperature is at least the threshold below the target and then stops only when the measured temperature reaches the target again. The default threshold is 0.25 C. + +The max runtime is stored in minutes and defaults to 5 minutes. It limits one continuous automatic pump run. If the pump runs for the configured max runtime without reaching the target temperature, the firmware switches the program mode to `off`, turns the pump off, persists the new mode, and emits a `program` SSE event with only the changed fields, such as `mode`, `pumpEnabled`, and `reason`, so the Program tile can update without overwriting settings the user is editing. Read the current program: @@ -331,10 +333,10 @@ POST /api/program Authorization: Bearer Content-Type: application/json -{"mode":"cool","targetTemperatureC":22.75} +{"mode":"cool","targetTemperatureC":22.75,"toleranceC":0.25,"maxRuntimeMinutes":5} ``` -The response includes `mode`, `targetTemperatureC`, `targetTemperatureF`, `toleranceC`, `pumpEnabled`, `sensorConnected`, and the latest `temperatureC`. +The response includes `mode`, `targetTemperatureC`, `targetTemperatureF`, `toleranceC`, `maxRuntimeMinutes`, `pumpEnabled`, `sensorConnected`, and the latest `temperatureC`. ### Authentication @@ -476,6 +478,57 @@ Content-Type: application/json Use `"role":"PUBLIC"` to make a known API public. +### Project scripts + +The `scripts/` directory contains small build and packaging helpers. They assume they are run from the project checkout and use the `seeed_xiao_esp32c3` PlatformIO environment by default. + +| Script | Purpose | +| --- | --- | +| `scripts/platformio_targets.py` | Registers the custom PlatformIO target `uploadall`, shown in the PlatformIO UI as `Upload Firmware and Filesystem`. | +| `scripts/create_update_package.py` | Combines an existing firmware image and LittleFS image into one `.tslpkg` OTA update package. | +| `scripts/build_ota_package.py` | Builds LittleFS and firmware, then creates a timestamped `.tslpkg` file in `dist/`. | +| `scripts/build_upload_device.py` | Builds LittleFS and firmware, then uploads both to a connected device. | + +Create a timestamped OTA package in `dist/`: + +```sh +python scripts/build_ota_package.py +``` + +Useful options: + +```sh +python scripts/build_ota_package.py -e seeed_xiao_esp32c3 -o dist +python scripts/build_ota_package.py --pio C:\Users\\.platformio\penv\Scripts\pio.exe +``` + +Upload firmware and filesystem to a connected device: + +```sh +python scripts/build_upload_device.py --upload-port COM3 +``` + +Useful options: + +```sh +python scripts/build_upload_device.py -e seeed_xiao_esp32c3 -p COM3 +python scripts/build_upload_device.py --pio C:\Users\\.platformio\penv\Scripts\pio.exe +``` + +Create a combined package from already-built images: + +```sh +pio run +pio run -t buildfs +python scripts/create_update_package.py +``` + +Useful options: + +```sh +python scripts/create_update_package.py --firmware .pio/build/seeed_xiao_esp32c3/firmware.bin --filesystem .pio/build/seeed_xiao_esp32c3/littlefs.bin --output .pio/build/seeed_xiao_esp32c3/update.tslpkg +``` + ### Firmware and filesystem updates Because the Admin UI is stored in LittleFS, OTA releases are distributed as one combined update package. The package contains both the LittleFS filesystem image and the firmware image, so UI and firmware changes cannot drift apart during an update. @@ -489,16 +542,13 @@ Because the Admin UI is stored in LittleFS, OTA releases are distributed as one Create the single OTA package: ```sh -pio run -pio run -t buildfs -python scripts/create_update_package.py +python scripts/build_ota_package.py ``` For a direct USB flash during development: ```sh -pio run -t upload -pio run -t uploadfs +python scripts/build_upload_device.py --upload-port COM3 ``` For OTA updates, host `update.tslpkg` on your update server. The Admin UI has one update package URL. The default is: diff --git a/data/www/components/program-card.js b/data/www/components/program-card.js index 0c8c8bf..d4c10cf 100644 --- a/data/www/components/program-card.js +++ b/data/www/components/program-card.js @@ -7,11 +7,11 @@ class ProgramCard extends HTMLElement { connectedCallback() { this.innerHTML = `

Program

-
+
@@ -46,6 +46,11 @@ class ProgramCard extends HTMLElement { this.renderPump(JSON.parse(event.data)); } catch (error) {} }); + this.source.addEventListener('program', event => { + try { + this.renderProgramChange(JSON.parse(event.data)); + } catch (error) {} + }); } stopEvents() { @@ -59,10 +64,13 @@ class ProgramCard extends HTMLElement { } renderSettings(json) { - const mode = json.mode || 'off'; - const modeInput = this.querySelector('input[name="programMode"][value="' + mode + '"]'); - if (modeInput) modeInput.checked = true; + if (json.mode !== undefined) { + const modeInput = this.querySelector('input[name="programMode"][value="' + json.mode + '"]'); + if (modeInput) modeInput.checked = true; + } if (json.targetTemperatureC !== undefined) this.querySelector('#target').value = Number(json.targetTemperatureC).toFixed(2); + if (json.toleranceC !== undefined) this.querySelector('#threshold').value = Number(json.toleranceC).toFixed(2); + if (json.maxRuntimeMinutes !== undefined) this.querySelector('#maxRuntime').value = String(Math.round(Number(json.maxRuntimeMinutes))); } renderLiveTemperature(json) { @@ -81,6 +89,11 @@ class ProgramCard extends HTMLElement { this.renderPump(json); } + renderProgramChange(json) { + this.renderSettings(json); + this.renderPump(json); + } + async loadProgram() { const text = await window.App.req('GET', '/api/program'); this.querySelector('#out').textContent = text; @@ -92,7 +105,9 @@ class ProgramCard extends HTMLElement { async saveProgram() { const target = Number(this.querySelector('#target').value); - const text = await window.App.req('POST', '/api/program', {mode: this.selectedMode(), targetTemperatureC: Number(target.toFixed(2))}); + const threshold = Number(this.querySelector('#threshold').value); + const maxRuntime = Number(this.querySelector('#maxRuntime').value); + const text = await window.App.req('POST', '/api/program', {mode: this.selectedMode(), targetTemperatureC: Number(target.toFixed(2)), toleranceC: Number(threshold.toFixed(2)), maxRuntimeMinutes: Math.round(maxRuntime)}); this.querySelector('#out').textContent = text; try { const json = JSON.parse(text); diff --git a/include/app.h b/include/app.h index 7579c8e..99f20af 100644 --- a/include/app.h +++ b/include/app.h @@ -38,6 +38,10 @@ #define PROGRAM_TOLERANCE_C 0.25f #endif +#ifndef PROGRAM_DEFAULT_MAX_RUNTIME_MINUTES +#define PROGRAM_DEFAULT_MAX_RUNTIME_MINUTES 5 +#endif + #ifndef PROJECT_NAME #define PROJECT_NAME "TSL Bed Cooler" #endif @@ -113,6 +117,8 @@ extern bool ledInverted; extern bool pumpEnabled; extern ProgramMode programMode; extern float programTargetC; +extern float programToleranceC; +extern uint16_t programMaxRuntimeMinutes; extern String updateUrl; extern String networkHostname; extern bool networkDhcp; diff --git a/src/core/state.cpp b/src/core/state.cpp index 951d8a6..9dcf1c4 100644 --- a/src/core/state.cpp +++ b/src/core/state.cpp @@ -30,6 +30,8 @@ bool ledInverted = DEFAULT_LED_INVERTED; bool pumpEnabled = false; ProgramMode programMode = PROGRAM_OFF; float programTargetC = PROGRAM_DEFAULT_TARGET_C; +float programToleranceC = PROGRAM_TOLERANCE_C; +uint16_t programMaxRuntimeMinutes = PROGRAM_DEFAULT_MAX_RUNTIME_MINUTES; String updateUrl = DEFAULT_UPDATE_URL; String networkHostname; bool networkDhcp = true; @@ -112,6 +114,10 @@ void loadSettings() { programMode = (ProgramMode)constrain(prefs.getUChar("progMode", PROGRAM_OFF), PROGRAM_OFF, PROGRAM_WARM); programTargetC = prefs.getFloat("progTargetC", PROGRAM_DEFAULT_TARGET_C); if (isnan(programTargetC) || programTargetC < -40.0f || programTargetC > 85.0f) programTargetC = PROGRAM_DEFAULT_TARGET_C; + programToleranceC = prefs.getFloat("progTolC", PROGRAM_TOLERANCE_C); + if (isnan(programToleranceC) || programToleranceC < 0.0f || programToleranceC > 20.0f) programToleranceC = PROGRAM_TOLERANCE_C; + programToleranceC = roundf(programToleranceC * 100.0f) / 100.0f; + programMaxRuntimeMinutes = (uint16_t)constrain((int)prefs.getUShort("progMaxMin", PROGRAM_DEFAULT_MAX_RUNTIME_MINUTES), 1, 1440); updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL); networkHostname = prefString("netHost", ""); networkDhcp = prefs.getBool("netDhcp", true); diff --git a/src/handlers/handlers_custom_api.cpp b/src/handlers/handlers_custom_api.cpp index 78d19e6..a1c6208 100644 --- a/src/handlers/handlers_custom_api.cpp +++ b/src/handlers/handlers_custom_api.cpp @@ -10,6 +10,8 @@ static bool temperatureStarted = false; static bool temperaturePending = false; static float lastTemperatureC = NAN; static uint32_t temperatureRequestMs = 0; +static uint32_t programPumpRunStartedMs = 0; +static uint32_t programModeSseUntilMs = 0; static void beginTemperatureSensor() { if (temperatureStarted) return; @@ -98,7 +100,8 @@ static String programJsonFields() { String json = "\"mode\":\"" + programModeName() + "\""; json += ",\"targetTemperatureC\":" + String(programTargetC, 2); json += ",\"targetTemperatureF\":" + String((programTargetC * 9.0f / 5.0f) + 32.0f, 2); - json += ",\"toleranceC\":" + String(PROGRAM_TOLERANCE_C, 2); + json += ",\"toleranceC\":" + String(programToleranceC, 2); + json += ",\"maxRuntimeMinutes\":" + String(programMaxRuntimeMinutes); json += ",\"pumpEnabled\":" + String(pumpEnabled ? "true" : "false"); json += ",\"sensorConnected\":"; json += isnan(temperatureC) ? "false" : "true"; @@ -115,22 +118,49 @@ void updateProgram() { lastProgramMs = now; if (programMode == PROGRAM_OFF) { + programPumpRunStartedMs = 0; setPumpEnabled(false, " by program"); return; } float temperatureC = currentTemperatureC(); if (isnan(temperatureC)) { + programPumpRunStartedMs = 0; setPumpEnabled(false, " by program sensor fault"); return; } float roundedTemperatureC = roundf(temperatureC * 100.0f) / 100.0f; + bool shouldRun = false; if (programMode == PROGRAM_COOL) { - setPumpEnabled(roundedTemperatureC >= programTargetC + PROGRAM_TOLERANCE_C, " by cool program"); + shouldRun = pumpEnabled + ? roundedTemperatureC > programTargetC + : roundedTemperatureC >= programTargetC + programToleranceC; } else if (programMode == PROGRAM_WARM) { - setPumpEnabled(roundedTemperatureC <= programTargetC - PROGRAM_TOLERANCE_C, " by warm program"); + shouldRun = pumpEnabled + ? roundedTemperatureC < programTargetC + : roundedTemperatureC <= programTargetC - programToleranceC; } + + if (!shouldRun) { + programPumpRunStartedMs = 0; + setPumpEnabled(false, " by " + programModeName() + " program"); + return; + } + + if (!pumpEnabled || programPumpRunStartedMs == 0) programPumpRunStartedMs = now; + uint32_t maxRuntimeMs = (uint32_t)programMaxRuntimeMinutes * 60000UL; + if (now - programPumpRunStartedMs >= maxRuntimeMs) { + programMode = PROGRAM_OFF; + prefs.putUChar("progMode", programMode); + programPumpRunStartedMs = 0; + programModeSseUntilMs = now + 15000UL; + setPumpEnabled(false, " by program max runtime"); + appendLog(LOG_WARN, "Program switched off after pump ran for " + String(programMaxRuntimeMinutes) + " minutes without reaching target"); + return; + } + + setPumpEnabled(true, " by " + programModeName() + " program"); } void handlePing() { @@ -170,10 +200,17 @@ void handleProgram() { programMode = parseProgramMode(jsonStringValue(body, "mode", programModeName()), programMode); programTargetC = constrain(jsonFloatValue(body, "targetTemperatureC", programTargetC), -40.0f, 85.0f); programTargetC = roundf(programTargetC * 100.0f) / 100.0f; + programToleranceC = constrain(jsonFloatValue(body, "toleranceC", programToleranceC), 0.0f, 20.0f); + programToleranceC = roundf(programToleranceC * 100.0f) / 100.0f; + programMaxRuntimeMinutes = (uint16_t)constrain(jsonIntValue(body, "maxRuntimeMinutes", programMaxRuntimeMinutes), 1, 1440); + programPumpRunStartedMs = 0; + programModeSseUntilMs = 0; prefs.putUChar("progMode", programMode); prefs.putFloat("progTargetC", programTargetC); + prefs.putFloat("progTolC", programToleranceC); + prefs.putUShort("progMaxMin", programMaxRuntimeMinutes); updateProgram(); - appendLog(LOG_INFO, "Program set to " + programModeName() + " target " + String(programTargetC, 2) + " C"); + appendLog(LOG_INFO, "Program set to " + programModeName() + " target " + String(programTargetC, 2) + " C threshold " + String(programToleranceC, 2) + " C max runtime " + String(programMaxRuntimeMinutes) + " min"); } sendJson(200, jsonOk(programJsonFields())); } @@ -190,6 +227,10 @@ void handleTemperatureEvents() { payload += "data: {" + temperatureJsonFields() + "}\n\n"; payload += "event: pump\n"; payload += "data: {" + pumpJsonFields() + "}\n\n"; + if ((int32_t)(millis() - programModeSseUntilMs) < 0) { + payload += "event: program\n"; + payload += "data: {\"mode\":\"" + programModeName() + "\",\"pumpEnabled\":" + String(pumpEnabled ? "true" : "false") + ",\"reason\":\"maxRuntime\"}\n\n"; + } server.sendHeader("Cache-Control", "no-store"); server.sendHeader("Connection", "close"); server.send(200, "text/event-stream", payload);