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
@@ -0,0 +1,96 @@
// Shared frontend helpers for the per-filament chamber-target map (#1468).
// Mirrors backend `PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS`. Values
// are chamber-temperature recommendations from BambuStudio's bundled filament
// profiles; users can override the whole map via the Settings → Workflow card.
export const DEFAULT_PREHEAT_FILAMENT_TARGETS: Record<string, number> = {
PLA: 0,
PETG: 0,
'PETG-CF': 40,
ABS: 45,
ASA: 45,
PA: 50,
'PA-CF': 55,
PC: 50,
'PC-FR': 50,
TPU: 0,
PVA: 0,
default: 0,
};
// Stable display order for the editor and any preview UI. Matches the order
// users will most likely tune (engineering filaments first, then commodity).
export const PREHEAT_FILAMENT_ORDER: readonly string[] = [
'PA-CF',
'PA',
'PC',
'PC-FR',
'ABS',
'ASA',
'PETG-CF',
'PETG',
'PLA',
'TPU',
'PVA',
'default',
];
export function parsePreheatFilamentTargets(raw: string): Record<string, number> {
if (!raw) return { ...DEFAULT_PREHEAT_FILAMENT_TARGETS };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const out: Record<string, number> = {};
for (const [key, value] of Object.entries(parsed)) {
const num = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(num)) {
out[key] = Math.max(0, Math.min(60, Math.round(num)));
}
}
if (out.default === undefined) out.default = DEFAULT_PREHEAT_FILAMENT_TARGETS.default;
return out;
}
} catch {
// fall through to defaults
}
return { ...DEFAULT_PREHEAT_FILAMENT_TARGETS };
}
export function serializePreheatFilamentTargets(map: Record<string, number>): string {
// Re-encode in the canonical display order so the on-disk JSON stays
// diff-stable. Strip 0 entries that match the default to keep the payload
// small — except `default` itself, which we always keep for predictability.
const out: Record<string, number> = {};
for (const key of PREHEAT_FILAMENT_ORDER) {
if (map[key] !== undefined) out[key] = map[key];
}
// Preserve any custom keys the user added that aren't in our canonical order.
for (const [key, value] of Object.entries(map)) {
if (!(key in out)) out[key] = value;
}
return JSON.stringify(out);
}
// Normalize a printer-reported tray_type to a map key. Mirrors the backend
// `_normalize_filament_type` — split on space, uppercase.
export function normalizePreheatFilamentType(trayType: string): string {
if (!trayType) return '';
return trayType.split(/\s+/)[0].toUpperCase();
}
// Pick the max chamber target across a list of loaded tray types, falling
// back to `default` when a type isn't in the map. Returns 0 when nothing
// is loaded, which short-circuits the chamber phase at dispatch.
export function deriveChamberTargetForTrays(
trayTypes: readonly string[],
map: Record<string, number>,
): number {
let best = 0;
for (const raw of trayTypes) {
const normalized = normalizePreheatFilamentType(raw);
if (!normalized) continue;
const target = map[normalized] ?? map.default ?? 0;
if (target > best) best = target;
}
return best;
}