mirror of
https://github.com/vincentvdwal/crust-craft.git
synced 2026-08-18 17:26:39 +02:00
update modes
This commit is contained in:
-15
@@ -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
@@ -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;
|
||||||
|
|||||||
+97
-23
@@ -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.relais = s.relais ? 0 : 1;
|
s.mode = 'manual';
|
||||||
this.lastSwitch = Date.now();
|
s.relais = s.relais ? 0 : 1;
|
||||||
}
|
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.
|
||||||
this.integral = Math.max(-100, Math.min(100, this.integral + s.ki * error * dt));
|
if (s.mode === 'baking' && now - this.bakeStart >= BAKE_WAIT_MS + BAKE_DURATION_MS) {
|
||||||
const output = Math.max(0, Math.min(100, s.kp * error + this.integral));
|
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;
|
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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
export const pad = (n: string | number, amount = 2) => {
|
export const pad = (n: string | number, amount = 2) => {
|
||||||
return String(n).padStart(amount, '0');
|
return String(n).padStart(amount, '0');
|
||||||
};
|
};
|
||||||
|
|||||||
+177
-84
@@ -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;
|
||||||
{
|
mode = Mode::MANUAL;
|
||||||
digitalWrite(relay, LOW);
|
digitalWrite(relay, turnOn ? HIGH : LOW);
|
||||||
lastSwitch = millis();
|
lastSwitch = millis();
|
||||||
mode = Mode::MANUAL;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
digitalWrite(relay, HIGH);
|
|
||||||
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);
|
|
||||||
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);
|
digitalWrite(relay, LOW);
|
||||||
lastSwitch = millis();
|
lastSwitch = millis();
|
||||||
}
|
}
|
||||||
|
else if (digitalRead(relay) == LOW && elapsed > pwmSwitchDelayOff)
|
||||||
if (pwmSwitchDelayOff > 0)
|
|
||||||
{
|
{
|
||||||
if ((millis() - lastSwitch) > pwmSwitchDelayOn)
|
digitalWrite(relay, HIGH);
|
||||||
{
|
lastSwitch = millis();
|
||||||
if (digitalRead(relay) == HIGH)
|
|
||||||
{
|
|
||||||
digitalWrite(relay, LOW);
|
|
||||||
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)
|
pid.setSetpoint(activeSetpoint()); // track target / boost / pause changes
|
||||||
{
|
power = pid.compute(temperature);
|
||||||
if (digitalRead(relay) == LOW)
|
applyPidRelay(power);
|
||||||
{
|
|
||||||
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
@@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-12
@@ -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.
|
||||||
integral += ki * error * dt;
|
double output = p_term + integral;
|
||||||
integral = clamp(integral, -integral_max, integral_max);
|
|
||||||
|
|
||||||
// // Derivative
|
// Integral with conditional-integration anti-windup.
|
||||||
// double d_term = 0.0;
|
// Only accumulate when we are NOT already saturated in the direction the
|
||||||
// if (!first_run)
|
// 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
|
||||||
// d_term = kd * (error - prev_error) / dt;
|
// and causes a large overshoot when the setpoint is finally reached.
|
||||||
// }
|
bool saturated_high = output >= max_output && error > 0.0;
|
||||||
// first_run = false;
|
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
|
// 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;
|
||||||
|
|||||||
+160
-27
@@ -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>
|
||||||
<p class="card-target">⌖ {targetTemp}°C</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>
|
||||||
|
{/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 / OFF {pwmOff ? pwmOff.toFixed(2) : 0} s
|
ON {pwmOn.toFixed(2)} s / OFF {pwmOff ? pwmOff.toFixed(2) : 0} s
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user