Fix Threshold and improve program and UI features

This commit is contained in:
2026-07-10 20:00:31 -05:00
parent c6213e1752
commit e56543e2fd
6 changed files with 137 additions and 18 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
dist/

View File

@@ -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 <token>
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\<user>\.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\<user>\.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:

View File

@@ -7,11 +7,11 @@ class ProgramCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}.radioRow{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0}.radioRow label{display:inline-flex;gap:5px;align-items:center;margin:0}.radioRow input{width:auto}.row{display:flex;gap:8px;align-items:end}.row label{flex:1}.status{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.badge{display:inline-flex;align-items:center;background:#eef3f6;border-radius:6px;padding:4px 7px;color:#263238;font-size:12px}
:host{display:block}section{height:100%;box-sizing:border-box}.radioRow{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0}.radioRow label{display:inline-flex;gap:5px;align-items:center;margin:0}.radioRow input{width:auto}.row{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.row label{flex:1;min-width:120px}.row button{flex:0 0 auto}.status{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.badge{display:inline-flex;align-items:center;background:#eef3f6;border-radius:6px;padding:4px 7px;color:#263238;font-size:12px}
</style>
<section>
<h3>Program</h3>
<div class="row"><label>Target Temperature<input id="target" type="number" min="-40" max="85" step="0.01" value="22.00"></label><button id="save">Set</button></div>
<div class="row"><label>Target Temperature<input id="target" type="number" min="-40" max="85" step="0.01" value="22.00"></label><label>Threshold<input id="threshold" type="number" min="0" max="20" step="0.01" value="0.25"></label><label>MaxRuntime<input id="maxRuntime" type="number" min="1" max="1440" step="1" value="5"></label><button id="save">Set</button></div>
<label>Mode</label>
<div class="radioRow">
<label><input type="radio" name="programMode" value="off"> Off</label>
@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);