update modes

This commit is contained in:
Vincent van der Wal
2026-06-10 17:53:23 +02:00
parent ce627ef7d4
commit 85b89797b7
8 changed files with 666 additions and 571 deletions
-15
View File
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>CrustCraft</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+216 -22
View File
@@ -15,6 +15,8 @@
--green: #22c55e; --green: #22c55e;
--red: #ef4444; --red: #ef4444;
--radius: 14px; --radius: 14px;
/* Accent colour of the active mode overwritten from JS, see +page.svelte */
--mode-color: #f97316;
} }
[data-theme='light'] { [data-theme='light'] {
@@ -52,11 +54,48 @@ p {
.nav { .nav {
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-top: 3px solid var(--mode-color);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; 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 { .nav-title {
@@ -165,6 +204,19 @@ p {
letter-spacing: 0.02em; 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 { @keyframes extreme-blink {
0%, 0%,
100% { 100% {
@@ -291,12 +343,12 @@ p {
@property --ovenColor1 { @property --ovenColor1 {
syntax: '<color>'; syntax: '<color>';
initial-value: rgba(255, 0, 0, 0); initial-value: rgba(255, 0, 0, 0);
inherits: false; inherits: true;
} }
@property --ovenColor2 { @property --ovenColor2 {
syntax: '<color>'; syntax: '<color>';
initial-value: rgba(255, 0, 0, 0); initial-value: rgba(255, 0, 0, 0);
inherits: false; inherits: true;
} }
.oven { .oven {
@@ -305,12 +357,8 @@ p {
} }
.oven-wrapper { .oven-wrapper {
background: radial-gradient( position: relative;
circle at center, isolation: isolate;
var(--ovenColor1) 0%,
var(--ovenColor2) 30%,
transparent 68%
);
width: 60%; width: 60%;
max-width: 480px; max-width: 480px;
display: flex; display: flex;
@@ -322,9 +370,41 @@ p {
--ovenColor2 2.5s; --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 { .oven.on .oven-wrapper {
--ovenColor1: rgba(255, 110, 0, 0.85); --ovenColor1: rgba(255, 120, 0, 0.85);
--ovenColor2: rgba(255, 60, 0, 0.4); --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 { .oven-img-wrapper {
@@ -397,21 +477,49 @@ p {
color: var(--text-muted); color: var(--text-muted);
} }
.mode-select { /* ── Mode buttons ──────────────────────────────────────── */
background: var(--surface-raised); .mode-btn-grid {
border: 1px solid var(--border); display: grid;
border-radius: 8px; grid-template-columns: repeat(3, 1fr);
color: var(--text); gap: 0.5rem;
font-size: 0.95rem;
padding: 0.4rem 0.6rem;
width: 100%; width: 100%;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
} }
.mode-select:focus { .mode-btn {
border-color: var(--accent); 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) ───────────────────── */ /* ── Sub-controls (PWM / PID params) ───────────────────── */
@@ -467,6 +575,92 @@ p {
color: var(--accent); 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 ───────────────────────────────────────────── */ /* ── Utility ───────────────────────────────────────────── */
.hidden { .hidden {
display: none !important; display: none !important;
+94 -20
View File
@@ -8,7 +8,7 @@
* Newton's law of cooling when relay is off. * Newton's law of cooling when relay is off.
* Small Gaussian noise on each tick for realism. * 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 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 MAX_HEAT_RATE = 14; // °C/s at peak of S-curve
const NOISE_AMP = 0.35; // °C peak random noise 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. */ /** S-curve heating factor: parabolic 4x(1-x), peaks at x = 0.5. */
function heatFactor(temp: number, target: number): number { function heatFactor(temp: number, target: number): number {
if (target <= ROOM_TEMP) return 0; if (target <= ROOM_TEMP) return 0;
@@ -33,12 +39,16 @@ type SensorPayload = {
temperature: number; temperature: number;
relais: number; relais: number;
target_temp: number; target_temp: number;
pause_temp: number;
bake_boost: number;
pwm_on: number; pwm_on: number;
pwm_off: number; pwm_off: number;
pid: number; pid: number;
kp: number; kp: number;
ki: number; ki: number;
kd: number; kd: number;
bake_phase: string;
bake_remaining: number;
}; };
type Command = { cmd: string; value?: number | string }; type Command = { cmd: string; value?: number | string };
@@ -50,20 +60,25 @@ export class SpoofSocket {
readyState: number = WebSocket.CONNECTING; readyState: number = WebSocket.CONNECTING;
private s: SensorPayload = { private s: SensorPayload = {
mode: 'manual', mode: 'preheat',
temperature: ROOM_TEMP, temperature: ROOM_TEMP,
relais: 0, relais: 0,
target_temp: 460, target_temp: 460,
pause_temp: PAUSE_TEMP,
bake_boost: BAKE_BOOST,
pwm_on: 2000, pwm_on: 2000,
pwm_off: 4000, pwm_off: 4000,
pid: 0, pid: 0,
kp: 0.6, kp: 0.6,
ki: 0.1, ki: 0.1,
kd: 0.0 kd: 0.0,
bake_phase: '',
bake_remaining: 0
}; };
private integral = 0; private integral = 0;
private lastSwitch = Date.now(); private lastSwitch = Date.now();
private bakeStart = 0;
private ticker: ReturnType<typeof setInterval> | null = null; private ticker: ReturnType<typeof setInterval> | null = null;
constructor() { constructor() {
@@ -98,6 +113,30 @@ export class SpoofSocket {
this.onclose?.(); 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 { private handle(cmd: string, value?: number | string): void {
const s = this.s; const s = this.s;
switch (cmd) { switch (cmd) {
@@ -105,19 +144,22 @@ export class SpoofSocket {
this.emit(); this.emit();
break; break;
case 'setTargetTemp': case 'setTargetTemp':
s.target_temp = Number(value); s.target_temp = Math.max(0, Math.min(600, Number(value)));
this.integral = 0; // reset integral on setpoint change 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; break;
case 'setMode': case 'setMode':
s.mode = String(value); this.applyMode(String(value));
if (s.mode === 'manual') s.relais = 0;
this.integral = 0;
break; break;
case 'switchRelais': case 'switchRelais':
if (s.mode === 'manual') { // Manual override: take manual control and toggle the relay.
s.mode = 'manual';
s.relais = s.relais ? 0 : 1; s.relais = s.relais ? 0 : 1;
this.lastSwitch = Date.now(); this.lastSwitch = Date.now();
}
break; break;
case 'setPWMOn': case 'setPWMOn':
s.pwm_on = Number(value) * 1000; s.pwm_on = Number(value) * 1000;
@@ -152,30 +194,45 @@ export class SpoofSocket {
s.relais = 1; s.relais = 1;
this.lastSwitch = now; this.lastSwitch = now;
} }
} else if (s.mode === 'pid') { } else if (this.isPidMode(s.mode)) {
const error = s.target_temp - s.temperature; // 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)); 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)); }
output = Math.max(0, Math.min(100, pTerm + this.integral));
s.pid = output; 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 offDelay = s.pwm_on / (output / 100) - s.pwm_on;
const elapsed = now - this.lastSwitch; 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; s.relais = 0;
this.lastSwitch = now; this.lastSwitch = now;
} else if (s.relais === 0 && offDelay > 0 && elapsed > offDelay) { } else if (s.relais === 0 && elapsed > offDelay) {
s.relais = 1; s.relais = 1;
this.lastSwitch = now; this.lastSwitch = now;
} }
} else {
s.relais = 0;
} }
} }
// ── thermal model ──────────────────────────────────────────────────── // ── thermal model ────────────────────────────────────────────────────
if (s.relais) { 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 { } else {
s.temperature -= coolingRate(s.temperature) * dt; s.temperature -= coolingRate(s.temperature) * dt;
} }
@@ -186,6 +243,23 @@ export class SpoofSocket {
} }
private emit(): void { 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) });
} }
} }
+172 -79
View File
@@ -39,8 +39,19 @@ float kp = 0.6;
float ki = 0.1; float ki = 0.1;
float kd = 0.0; float kd = 0.0;
enum class Mode { MANUAL, PWM, PID }; // Heating modes. PREHEAT, PAUSE and BAKING are all PI-regulated and only
Mode mode = Mode::MANUAL; // 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); MAX6675 thermocouple(thermoCLK, thermoCS, thermoDO);
@@ -52,13 +63,62 @@ static const char *modeToStr(Mode m)
{ {
case Mode::PWM: case Mode::PWM:
return "pwm"; return "pwm";
case Mode::PID: case Mode::PREHEAT:
return "pid"; return "preheat";
case Mode::PAUSE:
return "pause";
case Mode::BAKING:
return "baking";
default: default:
return "manual"; 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 // Get Sensor Readings and return JSON object
String getSensorReadings() String getSensorReadings()
{ {
@@ -73,6 +133,8 @@ String getSensorReadings()
readings["relais"] = digitalRead(relay) == HIGH ? 1 : 0; readings["relais"] = digitalRead(relay) == HIGH ? 1 : 0;
readings["target_temp"] = targetTemp; readings["target_temp"] = targetTemp;
readings["pause_temp"] = pauseTemp;
readings["bake_boost"] = bakeBoost;
readings["pwm_on"] = pwmSwitchDelayOn; readings["pwm_on"] = pwmSwitchDelayOn;
readings["pwm_off"] = pwmSwitchDelayOff; readings["pwm_off"] = pwmSwitchDelayOff;
@@ -83,6 +145,28 @@ String getSensorReadings()
readings["ki"] = ki; readings["ki"] = ki;
readings["kd"] = kd; 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); serializeJson(readings, jsonString);
return jsonString; return jsonString;
} }
@@ -152,43 +236,49 @@ void handleWebSocketMessage(void *arg, uint8_t *data, size_t len)
} }
else if (strcmp(cmd, "switchRelais") == 0) else if (strcmp(cmd, "switchRelais") == 0)
{ {
// Manual override: tapping the relay drops into MANUAL and toggles it.
Serial.printf("\nSwitch Relais"); Serial.printf("\nSwitch Relais");
if (digitalRead(relay) == HIGH) bool turnOn = digitalRead(relay) != HIGH;
{
digitalWrite(relay, LOW);
lastSwitch = millis();
mode = Mode::MANUAL; mode = Mode::MANUAL;
} digitalWrite(relay, turnOn ? HIGH : LOW);
else
{
digitalWrite(relay, HIGH);
lastSwitch = millis(); lastSwitch = millis();
} }
}
else if (strcmp(cmd, "setTargetTemp") == 0) else if (strcmp(cmd, "setTargetTemp") == 0)
{ {
targetTemp = doc["value"].as<float>(); float value = doc["value"].as<float>();
pid.setSetpoint(targetTemp); 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); Serial.printf("\nTarget temp set to %.1f°C", targetTemp);
} }
else if (strcmp(cmd, "setPauseTemp") == 0)
{
pauseTemp = constrain(doc["value"].as<float>(), 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<float>(), 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) else if (strcmp(cmd, "setMode") == 0)
{ {
const char *value = doc["value"]; const char *value = doc["value"];
if (value && strcmp(value, "pwm") == 0) if (!value)
{ return;
mode = Mode::PWM; if (strcmp(value, "pwm") == 0)
} applyMode(Mode::PWM);
else if (value && strcmp(value, "pid") == 0) else if (strcmp(value, "preheat") == 0)
{ applyMode(Mode::PREHEAT);
mode = Mode::PID; else if (strcmp(value, "pause") == 0)
} applyMode(Mode::PAUSE);
else if (strcmp(value, "baking") == 0)
applyMode(Mode::BAKING);
else else
{ applyMode(Mode::MANUAL);
mode = Mode::MANUAL;
digitalWrite(relay, LOW);
lastSwitch = millis();
}
Serial.printf("\nMode set to %s", modeToStr(mode));
} }
else if (strcmp(cmd, "setKp") == 0) else if (strcmp(cmd, "setKp") == 0)
{ {
@@ -243,67 +333,68 @@ void initWebSocket()
server.addHandler(&ws); 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() void regulateRelais()
{ {
if (mode == Mode::PWM) 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); digitalWrite(relay, LOW);
lastSwitch = millis(); lastSwitch = millis();
} }
} else if (digitalRead(relay) == LOW && elapsed > pwmSwitchDelayOff)
if ((millis() - lastSwitch) > pwmSwitchDelayOff)
{
if (digitalRead(relay) == LOW)
{ {
digitalWrite(relay, HIGH); digitalWrite(relay, HIGH);
lastSwitch = millis(); lastSwitch = millis();
} }
return;
} }
}
if (mode == Mode::PID) if (isPidMode(mode))
{ {
// output = Kp×error + Ki×∫error×dt + Kd×(Δerror/Δt) // 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);
pid.setSetpoint(activeSetpoint()); // track target / boost / pause changes
power = pid.compute(temperature); power = pid.compute(temperature);
if (power > 0.0f) applyPidRelay(power);
{
pwmSwitchDelayOff = (pwmSwitchDelayOn / (power / 100.0f)) - pwmSwitchDelayOn;
}
else
{
pwmSwitchDelayOff = 0.0f;
digitalWrite(relay, LOW);
lastSwitch = millis();
}
if (pwmSwitchDelayOff > 0)
{
if ((millis() - lastSwitch) > pwmSwitchDelayOn)
{
if (digitalRead(relay) == HIGH)
{
digitalWrite(relay, LOW);
lastSwitch = millis();
}
}
}
if (pwmSwitchDelayOff > 0)
{
if ((millis() - lastSwitch) > pwmSwitchDelayOff)
{
if (digitalRead(relay) == LOW)
{
digitalWrite(relay, HIGH);
lastSwitch = millis();
}
}
}
} }
} }
@@ -311,22 +402,24 @@ void setup()
{ {
Serial.begin(9600); Serial.begin(9600);
pinMode(relay, OUTPUT); pinMode(relay, OUTPUT);
digitalWrite(relay, LOW); // boot with heating OFF; the PI loop drives the relay
initWiFi(); initWiFi();
initLittleFS(); initLittleFS();
initWebSocket(); initWebSocket();
digitalWrite(relay, HIGH);
// Web Server Root URL // Web Server Root URL
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
{ request->send(LittleFS, "/index.html", "text/html"); }); { request->send(LittleFS, "/index.html", "text/html"); });
server.serveStatic("/", LittleFS, "/"); 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.setSetpoint(targetTemp);
pid.setDt(SENSOR_INTERVAL / 1000); pid.setDt(SENSOR_INTERVAL / 1000.0f); // 0.1 s
ElegantOTA.begin(&server); ElegantOTA.begin(&server);
-387
View File
@@ -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<SVGGElement, unknown, null, undefined>;
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')
}
}
+14 -11
View File
@@ -47,21 +47,24 @@ double PIDController::compute(double current_temp)
// Proportional // Proportional
double p_term = kp * error; double p_term = kp * error;
// Integral + anti-windup // Tentative output using the integral as it currently stands.
double output = p_term + integral;
// 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 += ki * error * dt;
integral = clamp(integral, -integral_max, integral_max); integral = clamp(integral, -integral_max, integral_max);
}
// // Derivative
// double d_term = 0.0;
// if (!first_run)
// {
// d_term = kd * (error - prev_error) / dt;
// }
// first_run = false;
// Output // Output
double output = p_term + integral; // + d_term; output = clamp(p_term + integral, min_output, max_output);
output = clamp(output, min_output, max_output);
prev_error = error; prev_error = error;
return output; return output;
+159 -26
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { SpoofSocket } from '$lib/spoof'; import { SpoofSocket } from '$lib/spoof';
import { pad } from '$lib/utils';
import { Sun, Moon } from 'lucide-svelte'; import { Sun, Moon } from 'lucide-svelte';
import { import {
Chart, Chart,
@@ -34,41 +35,76 @@
let gateway = `ws://${page.url.host}/ws`; let gateway = `ws://${page.url.host}/ws`;
let ws: WebSocket; let ws: WebSocket;
let mode = $state('manual'); let mode = $state('preheat');
let temp = $state(0); let temp = $state(0);
let relais = $state(0); let relais = $state(0);
let targetTemp = $state(460); let targetTemp = $state(460);
let calculatedPidOutput = $state(0); let calculatedPidOutput = $state(0);
let bakeRemaining = $state(0);
let bakePhase = $state('');
let pauseTemp = $state(275);
let bakeBoost = $state(40);
let pwmOn = $state(2); let pwmOn = $state(2);
let pwmOff = $state(4); let pwmOff = $state(4);
let kp = $state(0.6); let kp = $state(0.6);
let ki = $state(0.1); let ki = $state(0.1);
let kd = $state(0.0);
let pauseGraphUpdate = $state(false); let pauseGraphUpdate = $state(false);
let darkMode = $state(true); 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(() => { $effect(() => {
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
if (chart) { if (chart) {
const tickColor = darkMode ? '#7a7f96' : '#6b7280'; const tickColor = darkMode ? '#7a7f96' : '#6b7280';
const gridColor = darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.07)'; const gridColor = darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.07)';
(chart.options.scales!.x as any).ticks.color = tickColor; type AxisStyle = { ticks: { color: string }; grid: { color: string } };
(chart.options.scales!.x as any).grid.color = gridColor; const x = chart.options.scales!.x as unknown as AxisStyle;
(chart.options.scales!.y as any).ticks.color = tickColor; const y = chart.options.scales!.y as unknown as AxisStyle;
(chart.options.scales!.y as any).grid.color = gridColor; x.ticks.color = tickColor;
x.grid.color = gridColor;
y.ticks.color = tickColor;
y.grid.color = gridColor;
chart.update('none'); chart.update('none');
} }
}); });
// Temperature colour / class helpers // Temperature colour / class helpers
const TEMP_ZONE_COLORS = [ const TEMP_ZONE_COLORS = [
{ max: 350, color: '#22c55e' }, { max: 300, color: '#22c55e' },
{ max: 420, color: '#eab308' }, { max: 370, color: '#eab308' },
{ max: 480, color: '#f97316' }, { max: 430, color: '#f97316' },
{ max: 520, color: '#ef4444' } { max: 470, color: '#ef4444' }
] as const; ] as const;
function tempColor(t: number): string { function tempColor(t: number): string {
@@ -76,7 +112,7 @@
return '#ef4444'; return '#ef4444';
} }
function tempIsExtreme(t: number): boolean { function tempIsExtreme(t: number): boolean {
return t >= 520; return t >= 470;
} }
// Custom Chart.js plugin that draws temperature zone bands behind the data // Custom Chart.js plugin that draws temperature zone bands behind the data
@@ -93,11 +129,11 @@
const width = right - left; const width = right - left;
const zones = [ const zones = [
{ min: 0, max: 350, fill: 'rgba(34,197,94,0.07)', stripe: false }, { min: 0, max: 300, fill: 'rgba(34,197,94,0.07)', stripe: false },
{ min: 350, max: 420, fill: 'rgba(234,179,8,0.09)', stripe: false }, { min: 300, max: 370, fill: 'rgba(234,179,8,0.09)', stripe: false },
{ min: 420, max: 480, fill: 'rgba(249,115,22,0.10)', stripe: false }, { min: 370, max: 430, fill: 'rgba(249,115,22,0.10)', stripe: false },
{ min: 480, max: 520, fill: 'rgba(239,68,68,0.12)', stripe: false }, { min: 430, max: 470, fill: 'rgba(239,68,68,0.12)', stripe: false },
{ min: 520, max: 700, fill: 'rgba(239,68,68,0.06)', stripe: true } { min: 470, max: 700, fill: 'rgba(239,68,68,0.06)', stripe: true }
]; ];
const labels = ['Low', 'Medium', 'High', 'Very High', 'Extreme']; const labels = ['Low', 'Medium', 'High', 'Very High', 'Extreme'];
@@ -263,12 +299,16 @@
temperature: number; temperature: number;
relais: number; relais: number;
target_temp: number; target_temp: number;
pause_temp: number;
bake_boost: number;
pwm_on: number; pwm_on: number;
pwm_off: number; pwm_off: number;
pid: number; pid: number;
kp: number; kp: number;
ki: number; ki: number;
kd: number; kd: number;
bake_phase: string;
bake_remaining: number;
}; };
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
@@ -283,7 +323,10 @@
calculatedPidOutput = data.pid; calculatedPidOutput = data.pid;
kp = data.kp; kp = data.kp;
ki = data.ki; 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(); const now = Date.now();
@@ -320,6 +363,18 @@
send('setTargetTemp', value); 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 changePwmOn = (e: Event) => {
const value = Number((e.target as HTMLInputElement).value); const value = Number((e.target as HTMLInputElement).value);
pwmOn = value; pwmOn = value;
@@ -332,9 +387,7 @@
send('setPWMOff', value); send('setPWMOff', value);
}; };
const changeMode = (e: Event) => { const setMode = (id: string) => send('setMode', id);
send('setMode', (e.target as HTMLSelectElement).value);
};
const changeKValue = (e: Event, k: string) => { const changeKValue = (e: Event, k: string) => {
const value = Number((e.target as HTMLInputElement).value); const value = Number((e.target as HTMLInputElement).value);
@@ -357,6 +410,11 @@
</script> </script>
<div class="nav"> <div class="nav">
<div class="mode-pill">
<span class="mode-pill-dot"></span>
<span class="mode-pill-icon">{modeMeta.icon}</span>
<span class="mode-pill-label">{modeMeta.label}</span>
</div>
<div class="nav-title">🍕 CrustCraft</div> <div class="nav-title">🍕 CrustCraft</div>
<button <button
class="dark-mode-btn" class="dark-mode-btn"
@@ -372,6 +430,29 @@
</div> </div>
<div class="content"> <div class="content">
<!-- Prominent bake countdown -->
{#if mode === 'baking'}
{#if bakePhase === 'wait'}
<div class="bake-banner bake-banner-wait">
<div class="bake-banner-head">
<span class="bake-banner-icon"></span>
<span>WARMING UP · reaching {targetTemp + bakeBoost}°C</span>
</div>
<div class="bake-timer">{fmtMMSS(bakeRemaining)}</div>
<div class="bake-banner-sub">bake starts once the oven is ready</div>
</div>
{:else}
<div class="bake-banner">
<div class="bake-banner-head">
<span class="bake-banner-icon">🍕</span>
<span>BAKING · boost +{bakeBoost}°C → {targetTemp + bakeBoost}°C</span>
</div>
<div class="bake-timer">{fmtMMSS(bakeRemaining)}</div>
<div class="bake-banner-sub">returns to Preheat when the timer ends</div>
</div>
{/if}
{/if}
<!-- Status row --> <!-- Status row -->
<div class="card-grid"> <div class="card-grid">
<div class="card"> <div class="card">
@@ -382,7 +463,14 @@
> >
{temp.toFixed(1)}<span class="card-unit">°C</span> {temp.toFixed(1)}<span class="card-unit">°C</span>
</p> </p>
{#if effectiveTarget !== null}
<p class="card-target">
<span class="target-old">{targetTemp}°C</span>
<span class="target-new">{effectiveTarget}°C</span>
</p>
{:else}
<p class="card-target">{targetTemp}°C</p> <p class="card-target">{targetTemp}°C</p>
{/if}
</div> </div>
<button class="card relay-card {relais ? 'relay-on' : 'relay-off'}" onclick={switchRelais}> <button class="card relay-card {relais ? 'relay-on' : 'relay-off'}" onclick={switchRelais}>
@@ -447,11 +535,56 @@
<!-- Mode --> <!-- Mode -->
<div class="card control-card"> <div class="card control-card">
<p class="card-label">Mode</p> <p class="card-label">Mode</p>
<select class="mode-select" name="modes" id="mode_selector" onchange={changeMode}> <div class="mode-btn-grid">
<option value="pwm">PWM</option> {#each MODES as m (m.id)}
<option value="pid">PID</option> <button
<option value="manual" selected>Manual</option> class="mode-btn {mode === m.id ? 'active' : ''}"
</select> style:--mc={m.color}
onclick={() => setMode(m.id)}
>
<span class="mode-btn-icon">{m.icon}</span>
<span class="mode-btn-label">{m.label}</span>
</button>
{/each}
</div>
{#if mode === 'pause'}
<div class="subcontrol mt-3">
<label class="sublabel" for="pause_temp">Hold temp</label>
<div class="input-row">
<input
class="num-input"
type="number"
min="0"
max="600"
value={pauseTemp}
id="pause_temp"
onchange={changePauseTemp}
/>
<span class="input-unit">°C</span>
</div>
</div>
{/if}
{#if mode === 'baking'}
<div class="subcontrol mt-3">
<label class="sublabel" for="bake_boost">Boost offset</label>
<div class="input-row">
<input
class="num-input"
type="number"
min="0"
max="150"
value={bakeBoost}
id="bake_boost"
onchange={changeBakeBoost}
/>
<span class="input-unit">°C</span>
</div>
</div>
<p class="subinfo mt-2">
{targetTemp + bakeBoost}°C · 1 min warm-up + 3 min bake, then Preheat
</p>
{/if}
{#if mode === 'pwm'} {#if mode === 'pwm'}
<div class="subcontrol-grid mt-3"> <div class="subcontrol-grid mt-3">
@@ -494,7 +627,7 @@
</p> </p>
{/if} {/if}
{#if mode === 'pid'} {#if isPidMode}
<p class="subinfo mt-3"> <p class="subinfo mt-3">
ON {pwmOn.toFixed(2)} s &nbsp;/&nbsp; OFF {pwmOff ? pwmOff.toFixed(2) : 0} s ON {pwmOn.toFixed(2)} s &nbsp;/&nbsp; OFF {pwmOff ? pwmOff.toFixed(2) : 0} s
</p> </p>