Files
crust-craft/src/lib/spoof.ts
T
2026-06-10 17:53:23 +02:00

266 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) });
}
}