mirror of
https://github.com/vincentvdwal/crust-craft.git
synced 2026-08-18 17:26:39 +02:00
266 lines
7.4 KiB
TypeScript
266 lines
7.4 KiB
TypeScript
/**
|
||
* SpoofSocket – a fake WebSocket that simulates the ESP32 firmware.
|
||
* Used in DEV mode only. Cast to WebSocket with `as unknown as WebSocket`.
|
||
*
|
||
* Thermal model:
|
||
* – S-curve heating: rate peaks at ~50% of the target range (fast in the
|
||
* middle, slow at start/end), matching how a real oven behaves.
|
||
* – Newton's law of cooling when relay is off.
|
||
* – Small Gaussian noise on each tick for realism.
|
||
*
|
||
* Control modes mirror the firmware: manual, pwm, preheat, pause, baking.
|
||
*/
|
||
|
||
const TICK_MS = 500; // matches WS_INTERVAL on the firmware
|
||
const ROOM_TEMP = 22; // °C ambient
|
||
const MAX_HEAT_RATE = 14; // °C/s at peak of S-curve
|
||
const NOISE_AMP = 0.35; // °C peak random noise
|
||
|
||
// PI-mode parameters – keep in sync with the firmware (main.cpp)
|
||
const PAUSE_TEMP = 275; // low keep-warm hold
|
||
const BAKE_BOOST = 40; // °C added on top of target while baking
|
||
const BAKE_WAIT_MS = 60 * 1000; // 1 min warm-up before baking
|
||
const BAKE_DURATION_MS = 3 * 60 * 1000; // 3 min bake, then back to preheat
|
||
|
||
/** S-curve heating factor: parabolic 4x(1-x), peaks at x = 0.5. */
|
||
function heatFactor(temp: number, target: number): number {
|
||
if (target <= ROOM_TEMP) return 0;
|
||
const x = Math.max(0, Math.min(1, (temp - ROOM_TEMP) / (target - ROOM_TEMP)));
|
||
return 4 * x * (1 - x) + 0.02; // +0.02 so the oven starts moving immediately
|
||
}
|
||
|
||
/** Newton's law of cooling – rate proportional to excess over ambient. */
|
||
function coolingRate(temp: number): number {
|
||
return (1.4 * Math.max(0, temp - ROOM_TEMP)) / 300;
|
||
}
|
||
|
||
type SensorPayload = {
|
||
mode: string;
|
||
temperature: number;
|
||
relais: number;
|
||
target_temp: number;
|
||
pause_temp: number;
|
||
bake_boost: number;
|
||
pwm_on: number;
|
||
pwm_off: number;
|
||
pid: number;
|
||
kp: number;
|
||
ki: number;
|
||
kd: number;
|
||
bake_phase: string;
|
||
bake_remaining: number;
|
||
};
|
||
|
||
type Command = { cmd: string; value?: number | string };
|
||
|
||
export class SpoofSocket {
|
||
onopen: (() => void) | null = null;
|
||
onclose: (() => void) | null = null;
|
||
onmessage: ((e: { data: string }) => void) | null = null;
|
||
readyState: number = WebSocket.CONNECTING;
|
||
|
||
private s: SensorPayload = {
|
||
mode: 'preheat',
|
||
temperature: ROOM_TEMP,
|
||
relais: 0,
|
||
target_temp: 460,
|
||
pause_temp: PAUSE_TEMP,
|
||
bake_boost: BAKE_BOOST,
|
||
pwm_on: 2000,
|
||
pwm_off: 4000,
|
||
pid: 0,
|
||
kp: 0.6,
|
||
ki: 0.1,
|
||
kd: 0.0,
|
||
bake_phase: '',
|
||
bake_remaining: 0
|
||
};
|
||
|
||
private integral = 0;
|
||
private lastSwitch = Date.now();
|
||
private bakeStart = 0;
|
||
private ticker: ReturnType<typeof setInterval> | null = null;
|
||
|
||
constructor() {
|
||
// Simulate async connection handshake
|
||
setTimeout(() => {
|
||
this.readyState = WebSocket.OPEN;
|
||
this.onopen?.();
|
||
this.ticker = setInterval(() => this.tick(), TICK_MS);
|
||
}, 80);
|
||
}
|
||
|
||
send(raw: string): void {
|
||
try {
|
||
const { cmd, value } = JSON.parse(raw) as Command;
|
||
this.handle(cmd, value);
|
||
} catch {
|
||
// ignore malformed messages
|
||
}
|
||
}
|
||
|
||
/** Stop the simulation ticker without triggering onclose (for cleanup). */
|
||
destroy(): void {
|
||
if (this.ticker !== null) {
|
||
clearInterval(this.ticker);
|
||
this.ticker = null;
|
||
}
|
||
}
|
||
|
||
close(): void {
|
||
this.destroy();
|
||
this.readyState = WebSocket.CLOSED;
|
||
this.onclose?.();
|
||
}
|
||
|
||
private isPidMode(m: string): boolean {
|
||
return m === 'preheat' || m === 'pause' || m === 'baking';
|
||
}
|
||
|
||
/** Effective setpoint the PI loop drives towards for the current mode. */
|
||
private activeSetpoint(): number {
|
||
const s = this.s;
|
||
if (s.mode === 'pause') return s.pause_temp;
|
||
if (s.mode === 'baking') return s.target_temp + s.bake_boost;
|
||
return s.target_temp;
|
||
}
|
||
|
||
private applyMode(m: string): void {
|
||
const s = this.s;
|
||
s.mode = m;
|
||
if (m === 'baking') this.bakeStart = Date.now();
|
||
if (m === 'manual') {
|
||
s.relais = 0;
|
||
this.lastSwitch = Date.now();
|
||
}
|
||
// Dump windup when the new target is below the current temp (e.g. -> pause)
|
||
if (this.isPidMode(m) && this.activeSetpoint() < s.temperature) this.integral = 0;
|
||
}
|
||
|
||
private handle(cmd: string, value?: number | string): void {
|
||
const s = this.s;
|
||
switch (cmd) {
|
||
case 'getReadings':
|
||
this.emit();
|
||
break;
|
||
case 'setTargetTemp':
|
||
s.target_temp = Math.max(0, Math.min(600, Number(value)));
|
||
break;
|
||
case 'setPauseTemp':
|
||
s.pause_temp = Math.max(0, Math.min(600, Number(value)));
|
||
break;
|
||
case 'setBakeOffset':
|
||
s.bake_boost = Math.max(0, Math.min(150, Number(value)));
|
||
break;
|
||
case 'setMode':
|
||
this.applyMode(String(value));
|
||
break;
|
||
case 'switchRelais':
|
||
// Manual override: take manual control and toggle the relay.
|
||
s.mode = 'manual';
|
||
s.relais = s.relais ? 0 : 1;
|
||
this.lastSwitch = Date.now();
|
||
break;
|
||
case 'setPWMOn':
|
||
s.pwm_on = Number(value) * 1000;
|
||
break;
|
||
case 'setPWMOff':
|
||
s.pwm_off = Number(value) * 1000;
|
||
break;
|
||
case 'setKp':
|
||
s.kp = Number(value);
|
||
break;
|
||
case 'setKi':
|
||
s.ki = Number(value);
|
||
break;
|
||
case 'setKd':
|
||
s.kd = Number(value);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private tick(): void {
|
||
const dt = TICK_MS / 1000;
|
||
const s = this.s;
|
||
const now = Date.now();
|
||
|
||
// ── relay control (mirrors firmware regulateRelais) ──────────────────
|
||
if (s.mode === 'pwm') {
|
||
const elapsed = now - this.lastSwitch;
|
||
if (s.relais === 1 && elapsed > s.pwm_on) {
|
||
s.relais = 0;
|
||
this.lastSwitch = now;
|
||
} else if (s.relais === 0 && elapsed > s.pwm_off) {
|
||
s.relais = 1;
|
||
this.lastSwitch = now;
|
||
}
|
||
} else if (this.isPidMode(s.mode)) {
|
||
// Baking = warm-up + bake; when it's all done, return to preheat.
|
||
if (s.mode === 'baking' && now - this.bakeStart >= BAKE_WAIT_MS + BAKE_DURATION_MS) {
|
||
this.applyMode('preheat');
|
||
}
|
||
|
||
const error = this.activeSetpoint() - s.temperature;
|
||
const pTerm = s.kp * error;
|
||
let output = pTerm + this.integral;
|
||
|
||
// Conditional-integration anti-windup (mirrors PIDController::compute)
|
||
const satHigh = output >= 100 && error > 0;
|
||
const satLow = output <= 0 && error < 0;
|
||
if (!satHigh && !satLow) {
|
||
this.integral = Math.max(-100, Math.min(100, this.integral + s.ki * error * dt));
|
||
}
|
||
output = Math.max(0, Math.min(100, pTerm + this.integral));
|
||
s.pid = output;
|
||
|
||
if (output >= 100) {
|
||
s.relais = 1;
|
||
} else if (output <= 0) {
|
||
s.relais = 0;
|
||
} else {
|
||
const offDelay = s.pwm_on / (output / 100) - s.pwm_on;
|
||
const elapsed = now - this.lastSwitch;
|
||
if (s.relais === 1 && elapsed > s.pwm_on) {
|
||
s.relais = 0;
|
||
this.lastSwitch = now;
|
||
} else if (s.relais === 0 && elapsed > offDelay) {
|
||
s.relais = 1;
|
||
this.lastSwitch = now;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── thermal model ────────────────────────────────────────────────────
|
||
if (s.relais) {
|
||
s.temperature += MAX_HEAT_RATE * heatFactor(s.temperature, this.activeSetpoint()) * dt;
|
||
} else {
|
||
s.temperature -= coolingRate(s.temperature) * dt;
|
||
}
|
||
s.temperature = Math.max(ROOM_TEMP, s.temperature);
|
||
s.temperature += (Math.random() - 0.5) * NOISE_AMP;
|
||
|
||
this.emit();
|
||
}
|
||
|
||
private emit(): void {
|
||
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) });
|
||
}
|
||
}
|