Implement bed cooler...

This commit is contained in:
2026-06-28 16:25:55 -05:00
parent e188930755
commit c6213e1752
16 changed files with 422 additions and 1 deletions

104
README.md
View File

@@ -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 <token>
```
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 <token>
```
```http
POST /api/pump
Authorization: Bearer <token>
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 <token>
```
Set the program:
```http
POST /api/program
Authorization: Bearer <token>
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` |

View File

@@ -7,7 +7,9 @@
<script defer src="/js/app.js"></script>
<script type="module" src="/components/status-card.js"></script>
<script type="module" src="/components/temperature-card.js"></script>
<script type="module" src="/components/program-card.js"></script>
<script type="module" src="/components/led-card.js"></script>
<script type="module" src="/components/pump-card.js"></script>
<script type="module" src="/components/add-card.js"></script>
</head>
<body>
@@ -31,6 +33,8 @@
<div id="dashboard" class="panel active"><div class="grid">
<status-card></status-card>
<temperature-card></temperature-card>
<program-card></program-card>
<pump-card></pump-card>
<led-card></led-card>
<add-card></add-card>
</div></div>

View File

@@ -0,0 +1,104 @@
class ProgramCard extends HTMLElement {
constructor() {
super();
this.source = null;
}
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}
</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>
<label>Mode</label>
<div class="radioRow">
<label><input type="radio" name="programMode" value="off"> Off</label>
<label><input type="radio" name="programMode" value="cool"> Cool</label>
<label><input type="radio" name="programMode" value="warm"> Warm</label>
</div>
<div class="status"><span id="temp" class="badge">--</span><span id="pump" class="badge">Pump --</span></div>
<pre id="out"></pre>
</section>`;
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);

View File

@@ -0,0 +1,51 @@
class PumpCard extends HTMLElement {
constructor() {
super();
this.enabled = false;
}
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}.pumpState{font-size:32px;font-weight:700;line-height:1.15;margin:10px 0}.stateRow{display:flex;gap:8px;align-items:center}.dot{width:10px;height:10px;border-radius:50%;background:#607d8b}.dot.on{background:#188038}.actions{display:flex;gap:8px;flex-wrap:wrap}
</style>
<section>
<h3>Pump</h3>
<div class="muted">DC pump switch</div>
<div class="stateRow"><span id="dot" class="dot"></span><div id="state" class="pumpState">--</div></div>
<div class="actions"><button id="toggle">Toggle</button><button id="refresh" class="secondary">Refresh</button></div>
<pre id="out"></pre>
</section>`;
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);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -3,5 +3,7 @@
void handlePing();
void handleAdd();
void handleLed();
void handlePump();
void handleProgram();
void handleTemperature();
void handleTemperatureEvents();

View File

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

View File

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

View File

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

View File

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

View File

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