diff --git a/index.html b/index.html deleted file mode 100644 index d39b490..0000000 --- a/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - CrustCraft - - - - - - - - - - \ No newline at end of file diff --git a/src/app.css b/src/app.css index 4f99420..c8a141d 100644 --- a/src/app.css +++ b/src/app.css @@ -15,6 +15,8 @@ --green: #22c55e; --red: #ef4444; --radius: 14px; + /* Accent colour of the active mode – overwritten from JS, see +page.svelte */ + --mode-color: #f97316; } [data-theme='light'] { @@ -52,11 +54,48 @@ p { .nav { background: var(--surface); border-bottom: 1px solid var(--border); + border-top: 3px solid var(--mode-color); padding: 0.75rem 1rem; display: flex; align-items: center; justify-content: center; position: relative; + transition: border-top-color 0.4s; +} + +/* Active-mode pill (left side of the nav) */ +.mode-pill { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 0.4rem; + background: color-mix(in srgb, var(--mode-color) 14%, var(--surface-raised)); + border: 1px solid color-mix(in srgb, var(--mode-color) 45%, transparent); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + transition: + background 0.4s, + border-color 0.4s; +} + +.mode-pill-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mode-color); + box-shadow: 0 0 8px var(--mode-color); +} + +@media (max-width: 540px) { + .mode-pill-label { + display: none; + } } .nav-title { @@ -165,6 +204,19 @@ p { letter-spacing: 0.02em; } +/* Active-setpoint override shown in Pause / Baking */ +.target-old { + text-decoration: line-through; + color: var(--text-muted); + font-weight: 500; + margin-right: 0.4rem; +} + +.target-new { + color: var(--mode-color); + font-weight: 700; +} + @keyframes extreme-blink { 0%, 100% { @@ -291,12 +343,12 @@ p { @property --ovenColor1 { syntax: ''; initial-value: rgba(255, 0, 0, 0); - inherits: false; + inherits: true; } @property --ovenColor2 { syntax: ''; initial-value: rgba(255, 0, 0, 0); - inherits: false; + inherits: true; } .oven { @@ -305,12 +357,8 @@ p { } .oven-wrapper { - background: radial-gradient( - circle at center, - var(--ovenColor1) 0%, - var(--ovenColor2) 30%, - transparent 68% - ); + position: relative; + isolation: isolate; width: 60%; max-width: 480px; display: flex; @@ -322,9 +370,41 @@ p { --ovenColor2 2.5s; } +/* Soft glow behind the oven – an oval rotated to follow the appliance's shape. */ +.oven-wrapper::before { + content: ''; + position: absolute; + inset: -22% -12%; + z-index: -1; + background: radial-gradient( + ellipse 58% 42% at 50% 54%, + var(--ovenColor1) 0%, + var(--ovenColor2) 38%, + transparent 72% + ); + transform: rotate(-8deg); + transition: inset 2.2s ease; +} + +/* Glow colours per heating mode (only while the relay is firing) */ .oven.on .oven-wrapper { - --ovenColor1: rgba(255, 110, 0, 0.85); - --ovenColor2: rgba(255, 60, 0, 0.4); + --ovenColor1: rgba(255, 120, 0, 0.85); + --ovenColor2: rgba(255, 70, 0, 0.4); +} + +[data-mode='baking'] .oven.on .oven-wrapper { + --ovenColor1: rgba(225, 30, 30, 0.95); + --ovenColor2: rgba(150, 0, 0, 0.5); +} + +[data-mode='pause'] .oven.on .oven-wrapper { + --ovenColor1: rgba(255, 178, 40, 0.78); + --ovenColor2: rgba(245, 140, 10, 0.34); +} + +/* Baking pushes the glow further outwards for a hotter look */ +[data-mode='baking'] .oven-wrapper::before { + inset: -45% -25%; } .oven-img-wrapper { @@ -397,21 +477,49 @@ p { color: var(--text-muted); } -.mode-select { - background: var(--surface-raised); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - font-size: 0.95rem; - padding: 0.4rem 0.6rem; +/* ── Mode buttons ──────────────────────────────────────── */ +.mode-btn-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; width: 100%; - outline: none; - cursor: pointer; - transition: border-color 0.15s; } -.mode-select:focus { - border-color: var(--accent); +.mode-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text-muted); + padding: 0.5rem 0.25rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 600; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s, + box-shadow 0.15s; +} + +.mode-btn-icon { + font-size: 1.1rem; + line-height: 1; +} + +.mode-btn:hover { + color: var(--text); + border-color: var(--mc); +} + +.mode-btn.active { + color: #fff; + background: var(--mc); + border-color: var(--mc); + box-shadow: 0 0 14px color-mix(in srgb, var(--mc) 55%, transparent); } /* ── Sub-controls (PWM / PID params) ───────────────────── */ @@ -467,6 +575,92 @@ p { color: var(--accent); } +/* ── Bake countdown banner ─────────────────────────────── */ +.bake-banner { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--red) 22%, var(--surface)), + var(--surface) + ); + border: 1px solid color-mix(in srgb, var(--red) 45%, transparent); + border-radius: var(--radius); + padding: 1rem 1.25rem 1.1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + box-shadow: 0 0 24px color-mix(in srgb, var(--red) 30%, transparent); + animation: bake-pulse 1.6s ease-in-out infinite; +} + +@keyframes bake-pulse { + 0%, + 100% { + box-shadow: 0 0 18px color-mix(in srgb, var(--red) 22%, transparent); + } + 50% { + box-shadow: 0 0 34px color-mix(in srgb, var(--red) 45%, transparent); + } +} + +/* Warm-up phase – amber instead of red, gentler pulse */ +.bake-banner-wait { + background: linear-gradient( + 135deg, + color-mix(in srgb, #eab308 20%, var(--surface)), + var(--surface) + ); + border-color: color-mix(in srgb, #eab308 48%, transparent); + animation: warm-pulse 1.9s ease-in-out infinite; +} + +.bake-banner-wait .bake-banner-head { + color: #eab308; +} + +@keyframes warm-pulse { + 0%, + 100% { + box-shadow: 0 0 16px color-mix(in srgb, #eab308 20%, transparent); + } + 50% { + box-shadow: 0 0 32px color-mix(in srgb, #eab308 45%, transparent); + } +} + +.bake-banner-head { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--red); +} + +.bake-banner-icon { + font-size: 1.05rem; +} + +.bake-timer { + font-size: 3.2rem; + font-weight: 800; + line-height: 1.05; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.bake-banner-sub { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ── Whole-UI mode accent ──────────────────────────────── */ +/* Tint the status/control cards with the active mode colour. */ +.card-value { + transition: color 0.3s; +} + /* ── Utility ───────────────────────────────────────────── */ .hidden { display: none !important; diff --git a/src/lib/spoof.ts b/src/lib/spoof.ts index 7ede018..422134c 100644 --- a/src/lib/spoof.ts +++ b/src/lib/spoof.ts @@ -8,7 +8,7 @@ * – Newton's law of cooling when relay is off. * – Small Gaussian noise on each tick for realism. * - * Control modes mirror the firmware: manual, pwm, pid. + * Control modes mirror the firmware: manual, pwm, preheat, pause, baking. */ const TICK_MS = 500; // matches WS_INTERVAL on the firmware @@ -16,6 +16,12 @@ const ROOM_TEMP = 22; // °C ambient const MAX_HEAT_RATE = 14; // °C/s at peak of S-curve const NOISE_AMP = 0.35; // °C peak random noise +// PI-mode parameters – keep in sync with the firmware (main.cpp) +const PAUSE_TEMP = 275; // low keep-warm hold +const BAKE_BOOST = 40; // °C added on top of target while baking +const BAKE_WAIT_MS = 60 * 1000; // 1 min warm-up before baking +const BAKE_DURATION_MS = 3 * 60 * 1000; // 3 min bake, then back to preheat + /** S-curve heating factor: parabolic 4x(1-x), peaks at x = 0.5. */ function heatFactor(temp: number, target: number): number { if (target <= ROOM_TEMP) return 0; @@ -33,12 +39,16 @@ type SensorPayload = { temperature: number; relais: number; target_temp: number; + pause_temp: number; + bake_boost: number; pwm_on: number; pwm_off: number; pid: number; kp: number; ki: number; kd: number; + bake_phase: string; + bake_remaining: number; }; type Command = { cmd: string; value?: number | string }; @@ -50,20 +60,25 @@ export class SpoofSocket { readyState: number = WebSocket.CONNECTING; private s: SensorPayload = { - mode: 'manual', + mode: 'preheat', temperature: ROOM_TEMP, relais: 0, target_temp: 460, + pause_temp: PAUSE_TEMP, + bake_boost: BAKE_BOOST, pwm_on: 2000, pwm_off: 4000, pid: 0, kp: 0.6, ki: 0.1, - kd: 0.0 + kd: 0.0, + bake_phase: '', + bake_remaining: 0 }; private integral = 0; private lastSwitch = Date.now(); + private bakeStart = 0; private ticker: ReturnType | null = null; constructor() { @@ -98,6 +113,30 @@ export class SpoofSocket { this.onclose?.(); } + private isPidMode(m: string): boolean { + return m === 'preheat' || m === 'pause' || m === 'baking'; + } + + /** Effective setpoint the PI loop drives towards for the current mode. */ + private activeSetpoint(): number { + const s = this.s; + if (s.mode === 'pause') return s.pause_temp; + if (s.mode === 'baking') return s.target_temp + s.bake_boost; + return s.target_temp; + } + + private applyMode(m: string): void { + const s = this.s; + s.mode = m; + if (m === 'baking') this.bakeStart = Date.now(); + if (m === 'manual') { + s.relais = 0; + this.lastSwitch = Date.now(); + } + // Dump windup when the new target is below the current temp (e.g. -> pause) + if (this.isPidMode(m) && this.activeSetpoint() < s.temperature) this.integral = 0; + } + private handle(cmd: string, value?: number | string): void { const s = this.s; switch (cmd) { @@ -105,19 +144,22 @@ export class SpoofSocket { this.emit(); break; case 'setTargetTemp': - s.target_temp = Number(value); - this.integral = 0; // reset integral on setpoint change + s.target_temp = Math.max(0, Math.min(600, Number(value))); + break; + case 'setPauseTemp': + s.pause_temp = Math.max(0, Math.min(600, Number(value))); + break; + case 'setBakeOffset': + s.bake_boost = Math.max(0, Math.min(150, Number(value))); break; case 'setMode': - s.mode = String(value); - if (s.mode === 'manual') s.relais = 0; - this.integral = 0; + this.applyMode(String(value)); break; case 'switchRelais': - if (s.mode === 'manual') { - s.relais = s.relais ? 0 : 1; - this.lastSwitch = Date.now(); - } + // Manual override: take manual control and toggle the relay. + s.mode = 'manual'; + s.relais = s.relais ? 0 : 1; + this.lastSwitch = Date.now(); break; case 'setPWMOn': s.pwm_on = Number(value) * 1000; @@ -152,30 +194,45 @@ export class SpoofSocket { s.relais = 1; this.lastSwitch = now; } - } else if (s.mode === 'pid') { - const error = s.target_temp - s.temperature; - this.integral = Math.max(-100, Math.min(100, this.integral + s.ki * error * dt)); - const output = Math.max(0, Math.min(100, s.kp * error + this.integral)); + } else if (this.isPidMode(s.mode)) { + // Baking = warm-up + bake; when it's all done, return to preheat. + if (s.mode === 'baking' && now - this.bakeStart >= BAKE_WAIT_MS + BAKE_DURATION_MS) { + this.applyMode('preheat'); + } + + const error = this.activeSetpoint() - s.temperature; + const pTerm = s.kp * error; + let output = pTerm + this.integral; + + // Conditional-integration anti-windup (mirrors PIDController::compute) + const satHigh = output >= 100 && error > 0; + const satLow = output <= 0 && error < 0; + if (!satHigh && !satLow) { + this.integral = Math.max(-100, Math.min(100, this.integral + s.ki * error * dt)); + } + output = Math.max(0, Math.min(100, pTerm + this.integral)); s.pid = output; - if (output > 0) { + if (output >= 100) { + s.relais = 1; + } else if (output <= 0) { + s.relais = 0; + } else { const offDelay = s.pwm_on / (output / 100) - s.pwm_on; const elapsed = now - this.lastSwitch; - if (s.relais === 1 && offDelay > 0 && elapsed > s.pwm_on) { + if (s.relais === 1 && elapsed > s.pwm_on) { s.relais = 0; this.lastSwitch = now; - } else if (s.relais === 0 && offDelay > 0 && elapsed > offDelay) { + } else if (s.relais === 0 && elapsed > offDelay) { s.relais = 1; this.lastSwitch = now; } - } else { - s.relais = 0; } } // ── thermal model ──────────────────────────────────────────────────── if (s.relais) { - s.temperature += MAX_HEAT_RATE * heatFactor(s.temperature, s.target_temp) * dt; + s.temperature += MAX_HEAT_RATE * heatFactor(s.temperature, this.activeSetpoint()) * dt; } else { s.temperature -= coolingRate(s.temperature) * dt; } @@ -186,6 +243,23 @@ export class SpoofSocket { } private emit(): void { - this.onmessage?.({ data: JSON.stringify(this.s) }); + const s = this.s; + if (s.mode === 'baking') { + const elapsed = Date.now() - this.bakeStart; + if (elapsed < BAKE_WAIT_MS) { + s.bake_phase = 'wait'; + s.bake_remaining = Math.max(0, Math.ceil((BAKE_WAIT_MS - elapsed) / 1000)); + } else { + s.bake_phase = 'bake'; + s.bake_remaining = Math.max( + 0, + Math.ceil((BAKE_WAIT_MS + BAKE_DURATION_MS - elapsed) / 1000) + ); + } + } else { + s.bake_phase = ''; + s.bake_remaining = 0; + } + this.onmessage?.({ data: JSON.stringify(s) }); } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8149555..958a8d1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,3 @@ export const pad = (n: string | number, amount = 2) => { - return String(n).padStart(amount, '0'); + return String(n).padStart(amount, '0'); }; diff --git a/src/main.cpp b/src/main.cpp index e33275e..09f739e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -39,8 +39,19 @@ float kp = 0.6; float ki = 0.1; float kd = 0.0; -enum class Mode { MANUAL, PWM, PID }; -Mode mode = Mode::MANUAL; +// Heating modes. PREHEAT, PAUSE and BAKING are all PI-regulated and only +// differ in the setpoint they drive towards. The oven always boots in PREHEAT +// (never MANUAL with the relay latched on). +enum class Mode { MANUAL, PWM, PREHEAT, PAUSE, BAKING }; +Mode mode = Mode::PREHEAT; + +// Setpoints / timing for the PI-based modes (pauseTemp / bakeBoost are UI-adjustable) +float pauseTemp = 275.0f; // low "keep-warm" hold +float bakeBoost = 40.0f; // °C added on top of target +constexpr unsigned long BAKE_WAIT_MS = 60UL * 1000; // 1 min warm-up before baking +constexpr unsigned long BAKE_DURATION_MS = 3UL * 60 * 1000; // 3 min bake, then -> PREHEAT + +unsigned long bakeStart = 0; // millis() when BAKING was entered MAX6675 thermocouple(thermoCLK, thermoCS, thermoDO); @@ -52,13 +63,62 @@ static const char *modeToStr(Mode m) { case Mode::PWM: return "pwm"; - case Mode::PID: - return "pid"; + case Mode::PREHEAT: + return "preheat"; + case Mode::PAUSE: + return "pause"; + case Mode::BAKING: + return "baking"; default: return "manual"; } } +static bool isPidMode(Mode m) +{ + return m == Mode::PREHEAT || m == Mode::PAUSE || m == Mode::BAKING; +} + +// Effective setpoint the PI controller drives towards for the current mode. +static float activeSetpoint() +{ + switch (mode) + { + case Mode::PAUSE: + return pauseTemp; + case Mode::BAKING: + return targetTemp + bakeBoost; + default: // PREHEAT (and any other PI mode) + return targetTemp; + } +} + +// Centralised mode switch so every entry point stays consistent and safe. +static void applyMode(Mode m) +{ + mode = m; + + if (m == Mode::BAKING) + bakeStart = millis(); + + if (m == Mode::MANUAL) + { + digitalWrite(relay, LOW); // never leave MANUAL with the relay latched on + lastSwitch = millis(); + } + + if (isPidMode(m)) + { + pid.setSetpoint(activeSetpoint()); + // Dump integral windup when the new target is below the current temp + // (e.g. PREHEAT -> PAUSE) so we don't keep heating into an overshoot. + if (activeSetpoint() < temperature) + pid.reset(); + } + + Serial.printf("\nMode set to %s", modeToStr(mode)); +} + // Get Sensor Readings and return JSON object String getSensorReadings() { @@ -73,6 +133,8 @@ String getSensorReadings() readings["relais"] = digitalRead(relay) == HIGH ? 1 : 0; readings["target_temp"] = targetTemp; + readings["pause_temp"] = pauseTemp; + readings["bake_boost"] = bakeBoost; readings["pwm_on"] = pwmSwitchDelayOn; readings["pwm_off"] = pwmSwitchDelayOff; @@ -83,6 +145,28 @@ String getSensorReadings() readings["ki"] = ki; readings["kd"] = kd; + // Baking runs in two phases: a "wait" warm-up, then the actual "bake". + // Report the current phase and the seconds left within that phase. + const char *bakePhase = ""; + unsigned long bakeRemaining = 0; + if (mode == Mode::BAKING) + { + unsigned long elapsed = millis() - bakeStart; + if (elapsed < BAKE_WAIT_MS) + { + bakePhase = "wait"; + bakeRemaining = (BAKE_WAIT_MS - elapsed) / 1000; + } + else + { + bakePhase = "bake"; + unsigned long total = BAKE_WAIT_MS + BAKE_DURATION_MS; + bakeRemaining = elapsed >= total ? 0 : (total - elapsed) / 1000; + } + } + readings["bake_phase"] = bakePhase; + readings["bake_remaining"] = bakeRemaining; + serializeJson(readings, jsonString); return jsonString; } @@ -152,43 +236,49 @@ void handleWebSocketMessage(void *arg, uint8_t *data, size_t len) } else if (strcmp(cmd, "switchRelais") == 0) { + // Manual override: tapping the relay drops into MANUAL and toggles it. Serial.printf("\nSwitch Relais"); - if (digitalRead(relay) == HIGH) - { - digitalWrite(relay, LOW); - lastSwitch = millis(); - mode = Mode::MANUAL; - } - else - { - digitalWrite(relay, HIGH); - lastSwitch = millis(); - } + bool turnOn = digitalRead(relay) != HIGH; + mode = Mode::MANUAL; + digitalWrite(relay, turnOn ? HIGH : LOW); + lastSwitch = millis(); } else if (strcmp(cmd, "setTargetTemp") == 0) { - targetTemp = doc["value"].as(); - pid.setSetpoint(targetTemp); + float value = doc["value"].as(); + targetTemp = constrain(value, 0.0f, 600.0f); // clamp to the UI's range + pid.setSetpoint(activeSetpoint()); // respects the BAKING boost Serial.printf("\nTarget temp set to %.1f°C", targetTemp); } + else if (strcmp(cmd, "setPauseTemp") == 0) + { + pauseTemp = constrain(doc["value"].as(), 0.0f, 600.0f); + if (isPidMode(mode)) + pid.setSetpoint(activeSetpoint()); + Serial.printf("\nPause temp set to %.1f°C", pauseTemp); + } + else if (strcmp(cmd, "setBakeOffset") == 0) + { + bakeBoost = constrain(doc["value"].as(), 0.0f, 150.0f); + if (isPidMode(mode)) + pid.setSetpoint(activeSetpoint()); + Serial.printf("\nBake offset set to %.1f°C", bakeBoost); + } else if (strcmp(cmd, "setMode") == 0) { const char *value = doc["value"]; - if (value && strcmp(value, "pwm") == 0) - { - mode = Mode::PWM; - } - else if (value && strcmp(value, "pid") == 0) - { - mode = Mode::PID; - } + if (!value) + return; + if (strcmp(value, "pwm") == 0) + applyMode(Mode::PWM); + else if (strcmp(value, "preheat") == 0) + applyMode(Mode::PREHEAT); + else if (strcmp(value, "pause") == 0) + applyMode(Mode::PAUSE); + else if (strcmp(value, "baking") == 0) + applyMode(Mode::BAKING); else - { - mode = Mode::MANUAL; - digitalWrite(relay, LOW); - lastSwitch = millis(); - } - Serial.printf("\nMode set to %s", modeToStr(mode)); + applyMode(Mode::MANUAL); } else if (strcmp(cmd, "setKp") == 0) { @@ -243,67 +333,68 @@ void initWebSocket() server.addHandler(&ws); } +// Translate a 0-100% PI output into relay switching with a fixed on-time and a +// computed off-time. Handles the saturation cases explicitly so the relay is +// never left in the wrong state (the old code could get stuck OFF at 100%). +void applyPidRelay(float pwr) +{ + if (pwr >= 100.0f) + { + pwmSwitchDelayOff = 0.0f; // full power: keep the relay on continuously + if (digitalRead(relay) != HIGH) + digitalWrite(relay, HIGH); + return; + } + if (pwr <= 0.0f) + { + pwmSwitchDelayOff = 0.0f; // no power: relay off + if (digitalRead(relay) != LOW) + digitalWrite(relay, LOW); + return; + } + + pwmSwitchDelayOff = (pwmSwitchDelayOn / (pwr / 100.0f)) - pwmSwitchDelayOn; + + unsigned long elapsed = millis() - lastSwitch; + if (digitalRead(relay) == HIGH && elapsed > pwmSwitchDelayOn) + { + digitalWrite(relay, LOW); + lastSwitch = millis(); + } + else if (digitalRead(relay) == LOW && elapsed > pwmSwitchDelayOff) + { + digitalWrite(relay, HIGH); + lastSwitch = millis(); + } +} + void regulateRelais() { if (mode == Mode::PWM) { - if ((millis() - lastSwitch) > pwmSwitchDelayOn) + unsigned long elapsed = millis() - lastSwitch; + if (digitalRead(relay) == HIGH && elapsed > pwmSwitchDelayOn) { - if (digitalRead(relay) == HIGH) - { - digitalWrite(relay, LOW); - lastSwitch = millis(); - } - } - - if ((millis() - lastSwitch) > pwmSwitchDelayOff) - { - if (digitalRead(relay) == LOW) - { - digitalWrite(relay, HIGH); - lastSwitch = millis(); - } - } - } - if (mode == Mode::PID) - { - // output = Kp×error + Ki×∫error×dt + Kd×(Δerror/Δt) - power = pid.compute(temperature); - if (power > 0.0f) - { - pwmSwitchDelayOff = (pwmSwitchDelayOn / (power / 100.0f)) - pwmSwitchDelayOn; - } - else - { - pwmSwitchDelayOff = 0.0f; digitalWrite(relay, LOW); lastSwitch = millis(); } - - if (pwmSwitchDelayOff > 0) + else if (digitalRead(relay) == LOW && elapsed > pwmSwitchDelayOff) { - if ((millis() - lastSwitch) > pwmSwitchDelayOn) - { - if (digitalRead(relay) == HIGH) - { - digitalWrite(relay, LOW); - lastSwitch = millis(); - } - } + digitalWrite(relay, HIGH); + lastSwitch = millis(); } + return; + } - if (pwmSwitchDelayOff > 0) - { + if (isPidMode(mode)) + { + // Baking = 1 min warm-up + 3 min bake; when it's done, return to PREHEAT. + if (mode == Mode::BAKING && (millis() - bakeStart) >= (BAKE_WAIT_MS + BAKE_DURATION_MS)) + applyMode(Mode::PREHEAT); - if ((millis() - lastSwitch) > pwmSwitchDelayOff) - { - if (digitalRead(relay) == LOW) - { - digitalWrite(relay, HIGH); - lastSwitch = millis(); - } - } - } + pid.setSetpoint(activeSetpoint()); // track target / boost / pause changes + power = pid.compute(temperature); + applyPidRelay(power); } } @@ -311,22 +402,24 @@ void setup() { Serial.begin(9600); pinMode(relay, OUTPUT); + digitalWrite(relay, LOW); // boot with heating OFF; the PI loop drives the relay initWiFi(); initLittleFS(); initWebSocket(); - digitalWrite(relay, HIGH); - // Web Server Root URL server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(LittleFS, "/index.html", "text/html"); }); server.serveStatic("/", LittleFS, "/"); - // Create PID controller instance + // Create PID controller instance. + // NOTE: SENSOR_INTERVAL / 1000 is integer division (100/1000 == 0), which set + // dt to 0 and silently disabled the integral term — that was the real cause + // of the steady-state offset (curve flat-lining well below the setpoint). pid.setSetpoint(targetTemp); - pid.setDt(SENSOR_INTERVAL / 1000); + pid.setDt(SENSOR_INTERVAL / 1000.0f); // 0.1 s ElegantOTA.begin(&server); diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 27920f0..0000000 --- a/src/main.ts +++ /dev/null @@ -1,387 +0,0 @@ - -import * as d3 from "d3"; - - -import './styles.css' - - -let gateway = `ws://${window.location.hostname}/ws`; -let ws: WebSocket; - -const now = new Date() - -let container: HTMLElement | null = document.querySelector('#plot') - -let mode = 'off' -let temp = 300; -let relais = 0 -let targetTemp = 300 -let setOverShoot = 20 -let setUnderShoot = 20 -let targetTempOverShoot = targetTemp -let targetTempUnderShoot = targetTemp - -let lastSwitch = new Date() -let lastSwitchDuration = 0 - -const oven = document.querySelector('#oven') -const temperature = document.querySelector('#temperature') -const relaisSwitch = document.querySelector('#relais_switch') -const relaisSwitchInput: HTMLInputElement | null = document.querySelector('#relais_switch_input') -const targetTempInput: HTMLInputElement | null = document.querySelector('#target_temperature') -const modeSpan: HTMLInputElement | null = document.querySelector('#mode_span') -const modeSelector: HTMLInputElement | null = document.querySelector('#mode_selector') -const prepareDownload: HTMLElement | null = document.querySelector('#prepare_download') -const downloadCSVButton: HTMLElement | null = document.querySelector('#download_csv') - -let spoofInterval = 250 -let speedFactor = 250 / spoofInterval -const switchDelay = (100 / speedFactor) * 1000 - -let maxValues = 4 * 60 * 60 // 60 minutes (4 values/sec) - - -let temperatureData = [ - [now.getTime(), temp] -] -let relaisData = [ - [now.getTime(), 0] -] - -const getRelaisBands = () => { - let relaisBands = [] - let active = false; - - for (let r of relaisData) { - if (r[1] === 1 && !active) { - active = true - relaisBands.push({ - from: r[0], - to: relaisData[relaisData.length - 1][0] + 1000, - color: 'rgba(255, 0, 0, 0.25)' - - }) - } - if (r[1] === 0 && active) { - relaisBands[relaisBands.length - 1]['to'] = r[0] - active = false - } - } - return relaisBands -} - - -// set the dimensions and margins of the graph -const margin = { top: 10, right: 30, bottom: 30, left: 60 }; -const width = 900 - margin.left - margin.right; -const height = 500 - margin.top - margin.bottom; -let svg: d3.Selection; - -const createChart = () => { - - // append the svg object to the body of the page - svg = d3.select(container) - .append("svg") - .attr("width", width + margin.left + margin.right) - .attr("height", height + margin.top + margin.bottom) - .append("g") - .attr("transform", - "translate(" + margin.left + "," + margin.top + ")"); - - // Declare the x (horizontal position) scale. - const x = d3.scaleUtc(d3.extent(aapl, d => d.date), [margin.left, width - margin.right]); - - // Declare the y (vertical position) scale. - const y = d3.scaleLinear([0, d3.max(aapl, d => d.close)], [height - margin.bottom, margin.top]); - - // Declare the line generator. - const line = d3.line() - .x(d => x(d.date)) - .y(d => y(d.close)); - - // Add the x-axis. - svg.append("g") - .attr("transform", `translate(0,${height - margin.bottom})`) - .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0)); - - // Add the y-axis, remove the domain line, add grid lines and a label. - svg.append("g") - .attr("transform", `translate(${margin.left},0)`) - .call(d3.axisLeft(y).ticks(height / 40)) - .call(g => g.select(".domain").remove()) - .call(g => g.selectAll(".tick line").clone() - .attr("x2", width - margin.left - margin.right) - .attr("stroke-opacity", 0.1)) - .call(g => g.append("text") - .attr("x", -margin.left) - .attr("y", 10) - .attr("fill", "currentColor") - .attr("text-anchor", "start") - .text("↑ Daily close ($)")); - - // Append a path for the line. - svg.append("path") - .attr("fill", "none") - .attr("stroke", "steelblue") - .attr("stroke-width", 1.5) - .attr("d", line(aapl)); -} - -const onLoad = () => { - initWebSocket(); - - - if (container) { - createChart() - - relaisSwitch?.addEventListener('click', switchRelais) - modeSelector?.addEventListener('change', changeMode) - targetTempInput?.addEventListener('change', changeTargetTemp) - prepareDownload?.addEventListener('click', downloadCSV) - - - if (import.meta.env.DEV) { - // spoof some test data - setInterval(() => { - if (temperatureData.length > maxValues) { - temperatureData.shift() - relaisData.shift() - } - let d = new Date() - - if (temp < (targetTemp / 2)) { - temp = temp + Math.random() * 0.5 - 0.125 - } else if (temp < (targetTemp / (3 / 2))) { - temp = temp + Math.random() * 0.5 - 0.15 - } else { - temp = temp + Math.random() * 0.5 - 0.25 - } - - let shootFactor = Math.min(((((d.getTime() - lastSwitch.getTime()) / speedFactor) - ((lastSwitchDuration * 0.5) / speedFactor))) / ((5 * 60 * 1000) / speedFactor), 1) // 5mins - shootFactor = shootFactor - - if (relais == 0) { - temp = temp - 0.3 * shootFactor - } else { - temp = temp + 0.3 * shootFactor - } - - let mod = temp % 0.25 - if (mod > 0.125) { - temp = temp - mod + 0.25 - } else { - temp = temp - mod - } - - if (temperature) - temperature.innerHTML = temp.toFixed(2); - - - let derivedShootFactor = Math.min(((d.getTime() - lastSwitch.getTime()) / speedFactor) / ((3 * 60 * 1000) / speedFactor), 1) // 5mins - - let overshoot = setOverShoot * derivedShootFactor; // derive over 5 mins -> 1 min eq. 3°C, 5mins eq. 15°C - let dirivedOvershoot = Math.min(overshoot, setOverShoot); - - let undershoot = setUnderShoot * derivedShootFactor; // derive over 5 min -> 1 min eq. 3°C, 5mins eq. 15°C - let dirivedUndershoot = Math.min(undershoot, setUnderShoot); - - targetTempOverShoot = targetTemp - dirivedOvershoot; - targetTempUnderShoot = targetTemp + dirivedUndershoot; - - if (mode === 'auto_switch') { - if ((d.getTime() - lastSwitch.getTime()) > switchDelay) { - if (temp > targetTempOverShoot) // > 270 °C - { - if (relais == 1) { - relais = 0; - oven?.classList.remove('on') - relaisSwitch?.classList.remove('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = false - - targetTempOverShoot = targetTemp - targetTempUnderShoot = targetTemp - lastSwitchDuration = d.getTime() - lastSwitch.getTime() - lastSwitch = new Date() - } - } - } - if ((d.getTime() - lastSwitch.getTime()) > switchDelay) { - if (temp < targetTempUnderShoot) // < 310°C - { - if (relais == 0) { - relais = 1; - oven?.classList.add('on') - relaisSwitch?.classList.add('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = true - - targetTempOverShoot = targetTemp - targetTempUnderShoot = targetTemp - lastSwitchDuration = d.getTime() - lastSwitch.getTime() - lastSwitch = new Date() - } - } - } - } - - temperatureData.push([d.getTime(), temp]) - relaisData.push([d.getTime(), relais]) - - //update chart - }, spoofInterval) - } - } -} - - - - -window.addEventListener('load', onLoad); - -const getReadings = () => { - ws.send("getReadings"); -} - -const initWebSocket = () => { - if (!import.meta.env.DEV) { - console.log('Trying to open a WebSocket connection…'); - ws = new WebSocket(gateway); - ws.onopen = onOpen; - ws.onclose = onClose; - ws.onmessage = onMessage; - } -} - -// When websocket is established, call the getReadings() function -const onOpen = () => { - console.log('Connection opened'); - getReadings(); -} - -const onClose = () => { - console.log('Connection closed'); - setTimeout(initWebSocket, 2000); -} - -// Function that receives the message from the ESP32 with the readings -const onMessage = (event: MessageEvent) => { - let myObj = JSON.parse(event.data); - let keys = Object.keys(myObj); - - for (var i = 0; i < keys.length; i++) { - let key = keys[i]; - let element = document.getElementById(key) - if (element) { - element.innerHTML = myObj[key].toFixed(2); - } - let d = new Date() - if (key === 'temperature') { - if (temperatureData.length > maxValues) { - temperatureData.shift() - } - temperatureData.push([d.getTime(), myObj[key]]) - - } else if (key === 'relais') { - if (relaisData.length > maxValues) { - relaisData.shift() - } - relaisData.push([d.getTime(), myObj[key]]) - if (myObj[key]) { - oven?.classList.add('on') - relaisSwitch?.classList.add('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = true - } else { - oven?.classList.remove('on') - relaisSwitch?.classList.remove('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = false - } - } else if (key === 'target_temp') { - targetTemp = myObj[key] - } else if (key === 'derived_overshoot') { - targetTempOverShoot = myObj[key] - } else if (key === 'derived_undershoot') { - targetTempUnderShoot = myObj[key] - } else if (key === 'mode') { - mode = myObj[key] - if (modeSpan) { - modeSpan.innerText = mode; - } - } - - - } -} - -const switchRelais = () => { - console.log('Send switch relais') - if (!import.meta.env.DEV) { - ws.send("switchRelais"); - } else { - // spoof some test data - let d = new Date() - lastSwitchDuration = d.getTime() - lastSwitch.getTime() - lastSwitch = new Date() - if (!relais) { - relais = 1 - oven?.classList.add('on') - relaisSwitch?.classList.add('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = true - } else { - relais = 0 - oven?.classList.remove('on') - relaisSwitch?.classList.remove('on') - if (relaisSwitchInput) - relaisSwitchInput.checked = false - } - } -} - -const changeTargetTemp = (e: Event) => { - const target = e.target as HTMLTextAreaElement; - let value = Number(target?.value) - console.log('Changed target temp to ' + value) - if (!import.meta.env.DEV) { - ws.send("setTargetTemp: " + pad(value, 3)); - } else { - targetTemp = value - targetTempOverShoot = value - targetTempUnderShoot = value - } -} - -const changeMode = (e: Event) => { - const target = e.target as HTMLTextAreaElement; - let value = target?.value - console.log('Mode set to ' + value) - if (!import.meta.env.DEV) { - ws.send("setMode: " + value); - } else { - mode = value - if (modeSpan) { - modeSpan.innerText = mode + " "; - } - } -} - -const downloadCSV = () => { - let csvContent = 'Time,Temp,Relais\n' - - console.log(temperatureData.length, relaisData.length) - - for (let [i, temp] of temperatureData.entries()) { - - csvContent += `${temp[0]},${temp[1]},${relaisData[i] ? relaisData[i][1] : ''}\n` - } - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8,' }) - const objUrl = URL.createObjectURL(blob) - if (downloadCSVButton) { - downloadCSVButton.classList.remove('hidden') - downloadCSVButton.setAttribute('href', objUrl) - downloadCSVButton.setAttribute('download', 'data.csv') - } -} diff --git a/src/pid.cpp b/src/pid.cpp index 817f2d6..a49d4ae 100644 --- a/src/pid.cpp +++ b/src/pid.cpp @@ -47,21 +47,24 @@ double PIDController::compute(double current_temp) // Proportional double p_term = kp * error; - // Integral + anti-windup - integral += ki * error * dt; - integral = clamp(integral, -integral_max, integral_max); + // Tentative output using the integral as it currently stands. + double output = p_term + integral; - // // Derivative - // double d_term = 0.0; - // if (!first_run) - // { - // d_term = kd * (error - prev_error) / dt; - // } - // first_run = false; + // Integral with conditional-integration anti-windup. + // Only accumulate when we are NOT already saturated in the direction the + // error would push us further. During a long heat-up the output sits at + // 100% for minutes; without this guard the integral winds up enormously + // and causes a large overshoot when the setpoint is finally reached. + bool saturated_high = output >= max_output && error > 0.0; + bool saturated_low = output <= min_output && error < 0.0; + if (!saturated_high && !saturated_low) + { + integral += ki * error * dt; + integral = clamp(integral, -integral_max, integral_max); + } // Output - double output = p_term + integral; // + d_term; - output = clamp(output, min_output, max_output); + output = clamp(p_term + integral, min_output, max_output); prev_error = error; return output; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c80b7f2..e0a12dd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { SpoofSocket } from '$lib/spoof'; + import { pad } from '$lib/utils'; import { Sun, Moon } from 'lucide-svelte'; import { Chart, @@ -34,41 +35,76 @@ let gateway = `ws://${page.url.host}/ws`; let ws: WebSocket; - let mode = $state('manual'); + let mode = $state('preheat'); let temp = $state(0); let relais = $state(0); let targetTemp = $state(460); let calculatedPidOutput = $state(0); + let bakeRemaining = $state(0); + let bakePhase = $state(''); + let pauseTemp = $state(275); + let bakeBoost = $state(40); let pwmOn = $state(2); let pwmOff = $state(4); let kp = $state(0.6); let ki = $state(0.1); - let kd = $state(0.0); let pauseGraphUpdate = $state(false); let darkMode = $state(true); + // ── Modes ────────────────────────────────────────────── + // Preheat / Pause / Baking are all PI-regulated on the firmware; they only + // differ in the setpoint. Manual = hand control, PWM = fixed duty cycle. + // pauseTemp / bakeBoost are adjustable and come from the firmware. + type ModeMeta = { id: string; label: string; icon: string; color: string }; + const MODES: ModeMeta[] = [ + { id: 'preheat', label: 'Preheat', icon: '🔥', color: '#f97316' }, + { id: 'baking', label: 'Baking', icon: '🍕', color: '#ef4444' }, + { id: 'pause', label: 'Pause', icon: '⏸', color: '#14b8a6' }, + { id: 'pwm', label: 'PWM', icon: '⎍', color: '#38bdf8' }, + { id: 'manual', label: 'Manual', icon: '✋', color: '#9ca3af' } + ]; + const modeMeta = $derived(MODES.find((m) => m.id === mode) ?? MODES[0]); + const isPidMode = $derived(mode === 'preheat' || mode === 'pause' || mode === 'baking'); + + // The temperature the oven is actually driving towards right now. null when + // it equals the user's target (Preheat / Manual / PWM). + const effectiveTarget = $derived( + mode === 'baking' ? targetTemp + bakeBoost : mode === 'pause' ? pauseTemp : null + ); + + // Paint the whole UI with the active mode's accent colour. + $effect(() => { + document.documentElement.style.setProperty('--mode-color', modeMeta.color); + document.documentElement.setAttribute('data-mode', mode); + }); + + const fmtMMSS = (s: number) => `${Math.floor(s / 60)}:${pad(s % 60)}`; + $effect(() => { document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); if (chart) { const tickColor = darkMode ? '#7a7f96' : '#6b7280'; const gridColor = darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.07)'; - (chart.options.scales!.x as any).ticks.color = tickColor; - (chart.options.scales!.x as any).grid.color = gridColor; - (chart.options.scales!.y as any).ticks.color = tickColor; - (chart.options.scales!.y as any).grid.color = gridColor; + type AxisStyle = { ticks: { color: string }; grid: { color: string } }; + const x = chart.options.scales!.x as unknown as AxisStyle; + const y = chart.options.scales!.y as unknown as AxisStyle; + x.ticks.color = tickColor; + x.grid.color = gridColor; + y.ticks.color = tickColor; + y.grid.color = gridColor; chart.update('none'); } }); // Temperature colour / class helpers const TEMP_ZONE_COLORS = [ - { max: 350, color: '#22c55e' }, - { max: 420, color: '#eab308' }, - { max: 480, color: '#f97316' }, - { max: 520, color: '#ef4444' } + { max: 300, color: '#22c55e' }, + { max: 370, color: '#eab308' }, + { max: 430, color: '#f97316' }, + { max: 470, color: '#ef4444' } ] as const; function tempColor(t: number): string { @@ -76,7 +112,7 @@ return '#ef4444'; } function tempIsExtreme(t: number): boolean { - return t >= 520; + return t >= 470; } // Custom Chart.js plugin that draws temperature zone bands behind the data @@ -93,11 +129,11 @@ const width = right - left; const zones = [ - { min: 0, max: 350, fill: 'rgba(34,197,94,0.07)', stripe: false }, - { min: 350, max: 420, fill: 'rgba(234,179,8,0.09)', stripe: false }, - { min: 420, max: 480, fill: 'rgba(249,115,22,0.10)', stripe: false }, - { min: 480, max: 520, fill: 'rgba(239,68,68,0.12)', stripe: false }, - { min: 520, max: 700, fill: 'rgba(239,68,68,0.06)', stripe: true } + { min: 0, max: 300, fill: 'rgba(34,197,94,0.07)', stripe: false }, + { min: 300, max: 370, fill: 'rgba(234,179,8,0.09)', stripe: false }, + { min: 370, max: 430, fill: 'rgba(249,115,22,0.10)', stripe: false }, + { min: 430, max: 470, fill: 'rgba(239,68,68,0.12)', stripe: false }, + { min: 470, max: 700, fill: 'rgba(239,68,68,0.06)', stripe: true } ]; const labels = ['Low', 'Medium', 'High', 'Very High', 'Extreme']; @@ -263,12 +299,16 @@ temperature: number; relais: number; target_temp: number; + pause_temp: number; + bake_boost: number; pwm_on: number; pwm_off: number; pid: number; kp: number; ki: number; kd: number; + bake_phase: string; + bake_remaining: number; }; const onMessage = (event: MessageEvent) => { @@ -283,7 +323,10 @@ calculatedPidOutput = data.pid; kp = data.kp; ki = data.ki; - kd = data.kd; + bakeRemaining = data.bake_remaining ?? 0; + bakePhase = data.bake_phase ?? ''; + pauseTemp = data.pause_temp ?? pauseTemp; + bakeBoost = data.bake_boost ?? bakeBoost; const now = Date.now(); @@ -320,6 +363,18 @@ send('setTargetTemp', value); }; + const changePauseTemp = (e: Event) => { + const value = Number((e.target as HTMLInputElement).value); + pauseTemp = value; + send('setPauseTemp', value); + }; + + const changeBakeBoost = (e: Event) => { + const value = Number((e.target as HTMLInputElement).value); + bakeBoost = value; + send('setBakeOffset', value); + }; + const changePwmOn = (e: Event) => { const value = Number((e.target as HTMLInputElement).value); pwmOn = value; @@ -332,9 +387,7 @@ send('setPWMOff', value); }; - const changeMode = (e: Event) => { - send('setMode', (e.target as HTMLSelectElement).value); - }; + const setMode = (id: string) => send('setMode', id); const changeKValue = (e: Event, k: string) => { const value = Number((e.target as HTMLInputElement).value); @@ -357,6 +410,11 @@ + + {#if mode === 'pause'} +
+ +
+ + °C +
+
+ {/if} + {#if mode === 'baking'} +
+ +
+ + °C +
+
+

+ → {targetTemp + bakeBoost}°C · 1 min warm-up + 3 min bake, then Preheat +

+ {/if} {#if mode === 'pwm'}
@@ -494,7 +627,7 @@

{/if} - {#if mode === 'pid'} + {#if isPidMode}

ON {pwmOn.toFixed(2)} s  /  OFF {pwmOff ? pwmOff.toFixed(2) : 0} s