feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)

New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.

Resolution order at dispatch:

1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
   'off' skips entirely; 'inherit' falls back to the global
   preheat_enabled toggle; 'on' forces the stage even when the
   global is off.

2. chamber_target = item.preheat_chamber_target_override
                 ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                 ?? 0.
   Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
   requirement is binding, PLA doesn't suffer being warm). PLA-only
   derives 0 and skips the chamber phase automatically.

3. Three hardware tiers for chamber heat:
   - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
     chamber-sensor wait
   - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
     wait with hard max-wait cap
   - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
   match the chamber target — heating mode for engineering
   filaments, cooling mode for PLA. Bambu firmware does NOT
   auto-switch the flap with M141, so without this an ABS print
   on a previously-cooling flap fights the open exhaust, and a
   PLA print on a previously-hot flap recirculates ABS heat.
   Idempotent: only fires set_airduct_mode when current ≠ desired.

Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.

DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.

Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
This commit is contained in:
maziggy
2026-06-29 12:35:43 +02:00
parent a45d32efd0
commit 61a7f2e4ac
33 changed files with 1718 additions and 133 deletions
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
import { Settings, ChevronDown, ChevronUp, Flame } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';
import type { PrintOptionsProps, PrintOptions as PrintOptionsType, PreheatOverride } from './types';
type OptionConfig = {
key: keyof PrintOptionsType;
@@ -41,6 +41,29 @@ export function PrintOptionsPanel({
onChange({ ...options, [key]: !options[key] });
};
const handlePreheatOverride = (next: PreheatOverride) => {
onChange({
...options,
preheat_override: next,
// Clearing override→off also clears the chamber-target override so the
// backend doesn't carry a stale value if the user re-enables later.
...(next === 'off' ? { preheat_chamber_target_override: null } : {}),
});
};
const handlePreheatTarget = (raw: string) => {
if (raw === '') {
onChange({ ...options, preheat_chamber_target_override: null });
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) return;
onChange({
...options,
preheat_chamber_target_override: Math.max(0, Math.min(60, parsed)),
});
};
return (
<div className="mb-4">
<button
@@ -66,18 +89,66 @@ export function PrintOptionsPanel({
</div>
<div
className={`relative w-10 h-5 rounded-full transition-colors ${
options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
options[key as 'bed_levelling'] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
onClick={() => handleToggle(key)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
options[key] ? 'translate-x-5' : 'translate-x-0.5'
options[key as 'bed_levelling'] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</div>
</label>
))}
{/* Preheat / heat-soak per-item override (#1468). Defaults to
'inherit' which means the global Settings → Workflow toggle
decides. Forcing 'on' or 'off' overrides per-print; the chamber
target override (optional °C input, visible when not 'off')
bypasses the per-filament-type derivation. */}
<div className="pt-2 mt-1 border-t border-bambu-dark-tertiary/60">
<div className="flex items-center gap-2 mb-1.5">
<Flame className="w-3.5 h-3.5 text-amber-400" />
<span className="text-sm text-white">{t('settings.preheatTitle', 'Preheat & Heat Soak')}</span>
</div>
<p className="text-xs text-bambu-gray mb-2">
{t('settings.preheatPerItemDesc', 'Heat the bed and chamber before this print starts. Defaults to the global Settings → Workflow toggle.')}
</p>
<div className="flex gap-1.5 mb-2">
{(['inherit', 'on', 'off'] as PreheatOverride[]).map((opt) => (
<button
key={opt}
type="button"
onClick={() => handlePreheatOverride(opt)}
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${
options.preheat_override === opt
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
{t(`settings.preheatOverride_${opt}`, opt === 'inherit' ? 'Inherit' : opt === 'on' ? 'On' : 'Off')}
</button>
))}
</div>
{options.preheat_override !== 'off' && (
<div className="flex items-center gap-2">
<label className="text-xs text-bambu-gray flex-1">
{t('settings.preheatTargetOverride', 'Chamber target override (°C, blank = filament default)')}
</label>
<input
type="number"
min={0}
max={60}
step={1}
value={options.preheat_chamber_target_override ?? ''}
onChange={(e) => handlePreheatTarget(e.target.value)}
placeholder="—"
className="w-16 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-white text-xs text-right focus:outline-none focus:border-bambu-green"
/>
</div>
)}
</div>
</div>
)}
</div>
@@ -102,6 +102,8 @@ export function PrintModal({
layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
};
}
return DEFAULT_PRINT_OPTIONS;
@@ -239,6 +241,8 @@ export function PrintModal({
layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
});
}, [settings, mode]);
@@ -41,6 +41,8 @@ export interface PrintModalProps {
/**
* Print options that can be configured for a print job.
*/
export type PreheatOverride = 'inherit' | 'on' | 'off';
export interface PrintOptions {
bed_levelling: boolean;
flow_cali: boolean;
@@ -48,6 +50,12 @@ export interface PrintOptions {
layer_inspect: boolean;
timelapse: boolean;
nozzle_offset_cali: boolean;
// Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
// Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
// chamber_target_override is non-null to bypass the per-filament-type
// derivation with an explicit °C target.
preheat_override: PreheatOverride;
preheat_chamber_target_override: number | null;
}
/**
@@ -60,6 +68,8 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
layer_inspect: false,
timelapse: false,
nozzle_offset_cali: true,
preheat_override: 'inherit',
preheat_chamber_target_override: null,
};
/**