mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user