diff --git a/README.md b/README.md index ed604aa..605fafe 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino E - Public boilerplate APIs: ping, add, and LED brightness. - LED brightness is persisted in non-volatile memory, applied on boot, and loaded into the Admin UI slider. - DS18B20 temperature sensor readings are exposed in Celsius and Fahrenheit through the live Admin UI card and `/api/temperature` endpoint. +- A dashboard pump tile switches a 5 V DC pump through `/api/pump`. +- A dashboard Program tile can run the pump automatically from the measured temperature and a persisted target temperature. - User management with the standard roles `Sysadmin`, `UserAdmin`, `WebUIConnect`, and `Debugger`. - Custom role management. System roles are protected and cannot be deleted. - Active/inactive user accounts with role checkboxes in the Admin UI. @@ -236,6 +238,104 @@ GET /api/temperature/events Authorization: Bearer ``` +The SSE stream emits `temperature` events with the temperature payload and `pump` events with `enabled` and `pin`. + +### DC pump switch + +The dashboard includes a Pump tile that switches a 5 V DC pump on and off. The default control pin is GPIO5, exposed as `D3` on the Seeed Studio XIAO ESP32C3. + +Use a separate 5 V supply that can provide more than the pump's rated current. A 5 V, 3 W pump draws about 600 mA while running and can draw more at startup. The ESP32 pin must only drive the transistor base; never power the pump from an ESP32 GPIO pin. + +BC337 low-side switch schematic without a flyback diode: + +```text + +5 V pump supply + | + Pump + | + +------ C + | +GPIO5 / D3 -- 330 ohm to 1 kOhm -- B BC337 + | + +------ E + | +GND --------------------------+------------------ 5 V supply GND + +Optional: add 100 kOhm from BC337 base to GND to keep the pump off while the ESP32 boots. +``` + +Connections: + +| Circuit node | Connect to | +| --- | --- | +| Pump positive wire | External `+5 V` | +| Pump negative wire | BC337 collector | +| BC337 emitter | Common `GND` | +| BC337 base | GPIO5 / `D3` through a 330 Ohm to 1 kOhm resistor | +| ESP32 `GND` | External 5 V supply `GND` | + +This simplified diagram omits the flyback diode. A DC pump motor is an inductive load, so omitting the diode can let turn-off voltage spikes stress or damage the BC337 and possibly the ESP32. Use this version only if your pump module already includes suppression or you have another protection method. + +Check the BC337 pinout from the exact transistor datasheet or package marking before wiring it; TO-92 pin order is not universal across manufacturers. + +Important current note: a BC337 can switch this pump only marginally. At 600 mA, it may not saturate well from an ESP32 GPIO pin, can drop voltage, and can heat up. For reliable continuous use, replace the BC337 with a logic-level N-channel MOSFET rated for at least 1 A, keeping the same low-side layout. The BC327 is a PNP transistor and is not needed for this low-side switch. + +If you need a different control pin, override the default at compile time: + +```cpp +#define PUMP_PIN 5 +``` + +Pump API: + +```http +GET /api/pump +Authorization: Bearer +``` + +```http +POST /api/pump +Authorization: Bearer +Content-Type: application/json + +{"enabled":true} +``` + +The response includes `enabled` and `pin`. The pump defaults to off after boot. + +### Temperature program + +The dashboard Program tile controls the pump automatically from the DS18B20 temperature reading. Program settings are stored in NVS preferences, and the control loop runs in firmware even when no browser client is connected. + +Modes: + +| Mode | Behavior | +| --- | --- | +| `off` | Keeps the pump off | +| `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. + +Read the current program: + +```http +GET /api/program +Authorization: Bearer +``` + +Set the program: + +```http +POST /api/program +Authorization: Bearer +Content-Type: application/json + +{"mode":"cool","targetTemperatureC":22.75} +``` + +The response includes `mode`, `targetTemperatureC`, `targetTemperatureF`, `toleranceC`, `pumpEnabled`, `sensorConnected`, and the latest `temperatureC`. + ### Authentication Login: @@ -340,6 +440,10 @@ Protected APIs and their default roles: | --- | --- | | `POST /api/logout` | `WebUIConnect` | | `GET /api/me` | `WebUIConnect` | +| `GET /api/pump` | `WebUIConnect` | +| `POST /api/pump` | `WebUIConnect` | +| `GET /api/program` | `WebUIConnect` | +| `POST /api/program` | `WebUIConnect` | | `GET /api/temperature` | `WebUIConnect` | | `GET /api/temperature/events` | `WebUIConnect` | | `GET /api/apis` | `Sysadmin` | diff --git a/data/www/admin.html b/data/www/admin.html index e919ccc..2ec693c 100644 --- a/data/www/admin.html +++ b/data/www/admin.html @@ -7,7 +7,9 @@ + + @@ -31,6 +33,8 @@
+ +
diff --git a/data/www/components/program-card.js b/data/www/components/program-card.js new file mode 100644 index 0000000..0c8c8bf --- /dev/null +++ b/data/www/components/program-card.js @@ -0,0 +1,104 @@ +class ProgramCard extends HTMLElement { + constructor() { + super(); + this.source = null; + } + + connectedCallback() { + this.innerHTML = ` + +
+

Program

+
+ +
+ + + +
+
--Pump --
+

+      
`; + this.querySelector('#save').addEventListener('click', () => this.saveProgram()); + this.querySelectorAll('input[name="programMode"]').forEach(input => input.addEventListener('change', () => this.saveProgram())); + window.addEventListener('app:login', event => this.startEvents(event.detail.token)); + if (window.App && window.App.token()) this.startEvents(window.App.token()); + } + + disconnectedCallback() { + this.stopEvents(); + } + + startEvents(token) { + this.stopEvents(); + this.loadProgram(); + if (!token || !window.EventSource) return; + this.source = new EventSource('/api/temperature/events?token=' + encodeURIComponent(token)); + this.source.addEventListener('temperature', event => { + try { + this.renderLiveTemperature(JSON.parse(event.data)); + } catch (error) {} + }); + this.source.addEventListener('pump', event => { + try { + this.renderPump(JSON.parse(event.data)); + } catch (error) {} + }); + } + + stopEvents() { + if (this.source) this.source.close(); + this.source = null; + } + + selectedMode() { + const input = this.querySelector('input[name="programMode"]:checked'); + return input ? input.value : 'off'; + } + + renderSettings(json) { + const mode = json.mode || 'off'; + const modeInput = this.querySelector('input[name="programMode"][value="' + mode + '"]'); + if (modeInput) modeInput.checked = true; + if (json.targetTemperatureC !== undefined) this.querySelector('#target').value = Number(json.targetTemperatureC).toFixed(2); + } + + renderLiveTemperature(json) { + this.querySelector('#temp').textContent = json.sensorConnected && json.temperatureC !== null ? Number(json.temperatureC).toFixed(2) + ' C' : 'Sensor --'; + } + + renderPump(json) { + const enabled = json.enabled !== undefined ? json.enabled : json.pumpEnabled; + if (enabled === undefined) return; + this.querySelector('#pump').textContent = enabled ? 'Pump on' : 'Pump off'; + } + + render(json) { + this.renderSettings(json); + this.renderLiveTemperature(json); + this.renderPump(json); + } + + async loadProgram() { + const text = await window.App.req('GET', '/api/program'); + this.querySelector('#out').textContent = text; + try { + const json = JSON.parse(text); + if (json.success) this.render(json); + } catch (error) {} + } + + 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))}); + this.querySelector('#out').textContent = text; + try { + const json = JSON.parse(text); + if (json.success) this.render(json); + } catch (error) {} + } +} + +customElements.define('program-card', ProgramCard); diff --git a/data/www/components/pump-card.js b/data/www/components/pump-card.js new file mode 100644 index 0000000..751c883 --- /dev/null +++ b/data/www/components/pump-card.js @@ -0,0 +1,51 @@ +class PumpCard extends HTMLElement { + constructor() { + super(); + this.enabled = false; + } + + connectedCallback() { + this.innerHTML = ` + +
+

Pump

+
DC pump switch
+
--
+
+

+      
`; + this.querySelector('#toggle').addEventListener('click', () => this.setPump(!this.enabled)); + this.querySelector('#refresh').addEventListener('click', () => this.loadPump()); + window.addEventListener('app:login', () => this.loadPump()); + if (window.App && window.App.token()) this.loadPump(); + } + + render(json) { + this.enabled = !!json.enabled; + this.querySelector('#state').textContent = this.enabled ? 'On' : 'Off'; + this.querySelector('#dot').classList.toggle('on', this.enabled); + this.querySelector('#toggle').textContent = this.enabled ? 'Turn off' : 'Turn on'; + } + + async loadPump() { + const text = await window.App.req('GET', '/api/pump'); + this.querySelector('#out').textContent = text; + try { + const json = JSON.parse(text); + if (json.success) this.render(json); + } catch (error) {} + } + + async setPump(enabled) { + const text = await window.App.req('POST', '/api/pump', {enabled}); + this.querySelector('#out').textContent = text; + try { + const json = JSON.parse(text); + if (json.success) this.render(json); + } catch (error) {} + } +} + +customElements.define('pump-card', PumpCard); diff --git a/dist/TSL-Bed-Cooler_2026-06-28T15-32-33-05-00.tslpkg b/dist/TSL-Bed-Cooler_2026-06-28T15-32-33-05-00.tslpkg new file mode 100644 index 0000000..125b321 Binary files /dev/null and b/dist/TSL-Bed-Cooler_2026-06-28T15-32-33-05-00.tslpkg differ diff --git a/dist/TSL-Bed-Cooler_2026-06-28T15-49-27-05-00.tslpkg b/dist/TSL-Bed-Cooler_2026-06-28T15-49-27-05-00.tslpkg new file mode 100644 index 0000000..6137931 Binary files /dev/null and b/dist/TSL-Bed-Cooler_2026-06-28T15-49-27-05-00.tslpkg differ diff --git a/dist/TSL-Bed-Cooler_2026-06-28T15-57-28-05-00.tslpkg b/dist/TSL-Bed-Cooler_2026-06-28T15-57-28-05-00.tslpkg new file mode 100644 index 0000000..b066cbc Binary files /dev/null and b/dist/TSL-Bed-Cooler_2026-06-28T15-57-28-05-00.tslpkg differ diff --git a/dist/TSL-Bed-Cooler_2026-06-28T16-10-07-05-00.tslpkg b/dist/TSL-Bed-Cooler_2026-06-28T16-10-07-05-00.tslpkg new file mode 100644 index 0000000..3d5b3ba Binary files /dev/null and b/dist/TSL-Bed-Cooler_2026-06-28T16-10-07-05-00.tslpkg differ diff --git a/dist/TSL-Bed-Cooler_2026-06-28T16-18-54-05-00.tslpkg b/dist/TSL-Bed-Cooler_2026-06-28T16-18-54-05-00.tslpkg new file mode 100644 index 0000000..f6f45de Binary files /dev/null and b/dist/TSL-Bed-Cooler_2026-06-28T16-18-54-05-00.tslpkg differ diff --git a/include/app.h b/include/app.h index c687b6d..7579c8e 100644 --- a/include/app.h +++ b/include/app.h @@ -22,6 +22,22 @@ #define DS18B20_PIN 3 #endif +#ifndef PUMP_PIN +#define PUMP_PIN 5 +#endif + +#ifndef PUMP_ACTIVE_LEVEL +#define PUMP_ACTIVE_LEVEL HIGH +#endif + +#ifndef PROGRAM_DEFAULT_TARGET_C +#define PROGRAM_DEFAULT_TARGET_C 22.0f +#endif + +#ifndef PROGRAM_TOLERANCE_C +#define PROGRAM_TOLERANCE_C 0.25f +#endif + #ifndef PROJECT_NAME #define PROJECT_NAME "TSL Bed Cooler" #endif @@ -50,6 +66,12 @@ enum LogLevel : uint8_t { LOG_DEBUG = 4 }; +enum ProgramMode : uint8_t { + PROGRAM_OFF = 0, + PROGRAM_COOL = 1, + PROGRAM_WARM = 2 +}; + struct User { String name; String passwordHash; @@ -88,6 +110,9 @@ extern size_t maxLogBytes; extern bool setupMode; extern uint8_t ledBrightness; extern bool ledInverted; +extern bool pumpEnabled; +extern ProgramMode programMode; +extern float programTargetC; extern String updateUrl; extern String networkHostname; extern bool networkDhcp; @@ -136,6 +161,11 @@ bool authorize(); void appendLog(LogLevel level, const String &message); void applyLed(); +void applyPump(); +void updateTemperatureSensor(); +float currentTemperatureC(); +String programModeName(); +void updateProgram(); void factoryReset(); void checkFactoryResetPin(); bool connectWifi(); diff --git a/include/custom_api.h b/include/custom_api.h index 2c7a941..00264d9 100644 --- a/include/custom_api.h +++ b/include/custom_api.h @@ -3,5 +3,7 @@ void handlePing(); void handleAdd(); void handleLed(); +void handlePump(); +void handleProgram(); void handleTemperature(); void handleTemperatureEvents(); diff --git a/src/config/api_definitions_custom.cpp b/src/config/api_definitions_custom.cpp index 0d425e8..48232c7 100644 --- a/src/config/api_definitions_custom.cpp +++ b/src/config/api_definitions_custom.cpp @@ -5,6 +5,10 @@ ApiDef customApiDefs[] = { {"/api/ping", "GET", "", true, handlePing, nullptr}, {"/api/add", "POST", "", true, handleAdd, nullptr}, {"/api/led", "POST", "", true, handleLed, nullptr}, + {"/api/pump", "GET", "WebUIConnect", false, handlePump, nullptr}, + {"/api/pump", "POST", "WebUIConnect", false, handlePump, nullptr}, + {"/api/program", "GET", "WebUIConnect", false, handleProgram, nullptr}, + {"/api/program", "POST", "WebUIConnect", false, handleProgram, nullptr}, {"/api/temperature", "GET", "WebUIConnect", false, handleTemperature, nullptr}, {"/api/temperature/events", "GET", "WebUIConnect", false, handleTemperatureEvents, nullptr}, }; diff --git a/src/core/device.cpp b/src/core/device.cpp index b6855ea..58113fe 100644 --- a/src/core/device.cpp +++ b/src/core/device.cpp @@ -10,6 +10,10 @@ void applyLed() { analogWrite(LED_BUILTIN, duty); } +void applyPump() { + digitalWrite(PUMP_PIN, pumpEnabled ? PUMP_ACTIVE_LEVEL : !PUMP_ACTIVE_LEVEL); +} + void factoryReset() { prefs.clear(); LittleFS.remove(LOG_FILE_PATH); diff --git a/src/core/state.cpp b/src/core/state.cpp index aeda008..951d8a6 100644 --- a/src/core/state.cpp +++ b/src/core/state.cpp @@ -27,6 +27,9 @@ size_t maxLogBytes = 50 * 1024; bool setupMode = false; uint8_t ledBrightness = 0; bool ledInverted = DEFAULT_LED_INVERTED; +bool pumpEnabled = false; +ProgramMode programMode = PROGRAM_OFF; +float programTargetC = PROGRAM_DEFAULT_TARGET_C; String updateUrl = DEFAULT_UPDATE_URL; String networkHostname; bool networkDhcp = true; @@ -106,6 +109,9 @@ void loadSettings() { maxLogBytes = prefs.getUInt("logMax", 50 * 1024); ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED); ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100); + 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; 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 9b42bf1..78d19e6 100644 --- a/src/handlers/handlers_custom_api.cpp +++ b/src/handlers/handlers_custom_api.cpp @@ -22,7 +22,7 @@ static void beginTemperatureSensor() { temperatureStarted = true; } -static void updateTemperatureSensor() { +void updateTemperatureSensor() { beginTemperatureSensor(); uint32_t now = millis(); if (temperaturePending && now - temperatureRequestMs >= 750) { @@ -37,6 +37,11 @@ static void updateTemperatureSensor() { } } +float currentTemperatureC() { + updateTemperatureSensor(); + return lastTemperatureC; +} + static String temperatureJsonFields() { updateTemperatureSensor(); String json = "\"pin\":" + String(DS18B20_PIN); @@ -49,6 +54,85 @@ static String temperatureJsonFields() { return json; } +static String pumpJsonFields() { + return "\"enabled\":" + String(pumpEnabled ? "true" : "false") + ",\"pin\":" + String(PUMP_PIN); +} + +String programModeName() { + if (programMode == PROGRAM_COOL) return "cool"; + if (programMode == PROGRAM_WARM) return "warm"; + return "off"; +} + +static ProgramMode parseProgramMode(const String &value, ProgramMode fallback) { + String mode = value; + mode.toLowerCase(); + if (mode == "cool") return PROGRAM_COOL; + if (mode == "warm") return PROGRAM_WARM; + if (mode == "off") return PROGRAM_OFF; + return fallback; +} + +static float jsonFloatValue(const String &json, const char *key, float fallback) { + String needle = "\"" + String(key) + "\""; + int p = json.indexOf(needle); + if (p < 0) return fallback; + p = json.indexOf(':', p + needle.length()); + if (p < 0) return fallback; + p++; + while (p < (int)json.length() && isspace(json[p])) p++; + if (p >= (int)json.length()) return fallback; + if (json[p] == '"') return jsonStringValue(json, key, String(fallback, 2)).toFloat(); + return json.substring(p).toFloat(); +} + +static void setPumpEnabled(bool enabled, const String &reason) { + if (pumpEnabled == enabled) return; + pumpEnabled = enabled; + applyPump(); + appendLog(LOG_INFO, "Pump switched " + String(pumpEnabled ? "on" : "off") + reason); +} + +static String programJsonFields() { + float temperatureC = currentTemperatureC(); + 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 += ",\"pumpEnabled\":" + String(pumpEnabled ? "true" : "false"); + json += ",\"sensorConnected\":"; + json += isnan(temperatureC) ? "false" : "true"; + json += ",\"temperatureC\":"; + json += isnan(temperatureC) ? "null" : String(temperatureC, 2); + return json; +} + +void updateProgram() { + static uint32_t lastProgramMs = 0; + uint32_t now = millis(); + updateTemperatureSensor(); + if (now - lastProgramMs < 500) return; + lastProgramMs = now; + + if (programMode == PROGRAM_OFF) { + setPumpEnabled(false, " by program"); + return; + } + + float temperatureC = currentTemperatureC(); + if (isnan(temperatureC)) { + setPumpEnabled(false, " by program sensor fault"); + return; + } + + float roundedTemperatureC = roundf(temperatureC * 100.0f) / 100.0f; + if (programMode == PROGRAM_COOL) { + setPumpEnabled(roundedTemperatureC >= programTargetC + PROGRAM_TOLERANCE_C, " by cool program"); + } else if (programMode == PROGRAM_WARM) { + setPumpEnabled(roundedTemperatureC <= programTargetC - PROGRAM_TOLERANCE_C, " by warm program"); + } +} + void handlePing() { if (!authorize()) return; sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\"")); @@ -71,6 +155,29 @@ void handleLed() { sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness))); } +void handlePump() { + if (!authorize()) return; + if (server.method() == HTTP_POST) { + setPumpEnabled(jsonBoolValue(requestBody(), "enabled", server.arg("enabled") == "true"), " manually"); + } + sendJson(200, jsonOk(pumpJsonFields())); +} + +void handleProgram() { + if (!authorize()) return; + if (server.method() == HTTP_POST) { + String body = requestBody(); + programMode = parseProgramMode(jsonStringValue(body, "mode", programModeName()), programMode); + programTargetC = constrain(jsonFloatValue(body, "targetTemperatureC", programTargetC), -40.0f, 85.0f); + programTargetC = roundf(programTargetC * 100.0f) / 100.0f; + prefs.putUChar("progMode", programMode); + prefs.putFloat("progTargetC", programTargetC); + updateProgram(); + appendLog(LOG_INFO, "Program set to " + programModeName() + " target " + String(programTargetC, 2) + " C"); + } + sendJson(200, jsonOk(programJsonFields())); +} + void handleTemperature() { if (!authorize()) return; sendJson(200, jsonOk(temperatureJsonFields())); @@ -81,6 +188,8 @@ void handleTemperatureEvents() { String payload = "retry: 1000\n"; payload += "event: temperature\n"; payload += "data: {" + temperatureJsonFields() + "}\n\n"; + payload += "event: pump\n"; + payload += "data: {" + pumpJsonFields() + "}\n\n"; server.sendHeader("Cache-Control", "no-store"); server.sendHeader("Connection", "close"); server.send(200, "text/event-stream", payload); diff --git a/src/main.cpp b/src/main.cpp index 58410f1..63c0d9b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,8 @@ void setup() { loadSettings(); pinMode(LED_BUILTIN, OUTPUT); applyLed(); + pinMode(PUMP_PIN, OUTPUT); + applyPump(); checkFactoryResetPin(); bool configured = prefs.getBool("configured", false); @@ -34,5 +36,6 @@ void setup() { void loop() { if (setupMode) dnsServer.processNextRequest(); + updateProgram(); server.handleClient(); }