Raise the chamber-temperature ceiling from 60 to 65 C

Every field that takes a chamber target stopped at 60: the per-filament
    chamber map and per-print override in Preheat & Heat Soak, the chamber
    quick-select presets, and the printer-card chamber control. 60 is the
    X1E's ceiling and the X1E was the only heated-chamber model when that
    limit was written; the H2 series and X2D heat to 65, so the top of their
    range was unreachable.

    The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
    backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
    rather than as a literal at each call site. X1E firmware clamps a higher
    request to its own maximum, so a shared ceiling is safe.

    Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
    bounded to 60 there, and it rejects the whole triple on any out-of-range
    entry, so a saved 65 preset would have silently reverted the printer
    card to the defaults while Settings still showed 65.
This commit is contained in:
maziggy
2026-08-15 14:00:43 +02:00
15 changed files with 284 additions and 168 deletions
+7 -2
View File
@@ -64,7 +64,7 @@ from backend.app.services.printer_manager import (
)
from backend.app.utils.filament_ids import filament_id_to_setting_id
from backend.app.utils.http import build_content_disposition
from backend.app.utils.printer_models import uses_exhaust_fan_label
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["printers"])
@@ -3163,7 +3163,12 @@ async def set_bed_temperature(
@router.post("/{printer_id}/temperature/chamber")
async def set_chamber_temperature(
printer_id: int,
target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
target: int = Query(
...,
ge=0,
le=MAX_CHAMBER_TEMP_C,
description="Target chamber temperature in Celsius; 0 turns heating off",
),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
+5 -3
View File
@@ -3,6 +3,8 @@ from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# Custom serializer to ensure UTC datetimes have Z suffix
def serialize_utc_datetime(dt: datetime | None) -> str | None:
@@ -82,7 +84,7 @@ class PrintQueueItemCreate(BaseModel):
# preheat_enabled setting; 'on' / 'off' force the decision. The chamber
# target falls through: this override → max(filament-map[loaded tray]) → 0.
preheat_override: Literal["inherit", "on", "off"] = "inherit"
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
@@ -119,7 +121,7 @@ class PrintQueueItemUpdate(BaseModel):
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
# H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
@@ -277,7 +279,7 @@ class PrintQueueBulkUpdate(BaseModel):
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
+3 -2
View File
@@ -3,6 +3,7 @@ import json
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from backend.app.schemas.print_queue import TriState
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# Outbound service URLs validated on save, so a bad value is rejected at
# configuration time with a clear message rather than failing opaquely at
@@ -430,7 +431,7 @@ class AppSettings(BaseModel):
)
chamber_temp_presets: str = Field(
default="",
description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
description="JSON array of 3 chamber-temperature preset values in C (0-65). Empty = use defaults [35, 45, 60]",
)
fan_speed_presets: str = Field(
default="",
@@ -759,7 +760,7 @@ class AppSettingsUpdate(BaseModel):
@field_validator("chamber_temp_presets")
@classmethod
def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
return cls._validate_preset_triple(v, "chamber_temp_presets", 0, MAX_CHAMBER_TEMP_C)
@field_validator("fan_speed_presets")
@classmethod
+10
View File
@@ -264,6 +264,16 @@ def uses_exhaust_fan_label(model: str | None) -> bool:
return normalized in EXHAUST_FAN_LABEL_MODELS
# Ceiling for every chamber-temperature target the UI and API accept (manual
# M141, the preheat filament map, the per-item preheat override, the chamber
# quick-select presets). The H2 series (H2C / H2D / H2D Pro / H2S) and X2D
# heat the chamber to 65 °C; X1E tops out at 60. We validate against the
# highest of those and let the firmware clamp on the lower-ceiling models —
# the preheat filament map is global rather than per printer, so a per-model
# maximum could not be expressed there anyway.
MAX_CHAMBER_TEMP_C = 65
def has_ethernet(model: str | None) -> bool:
"""Return True if the printer model has an ethernet port."""
if not model:
@@ -3840,6 +3840,24 @@ class TestSetChamberTemperatureAPI:
response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ceiling_is_65_not_60(self, async_client: AsyncClient, printer_factory):
"""The H2 series heats the chamber to 65 °C — 65 must be accepted and
66 rejected. The route used to cap at 60, which put the top of the H2D
range out of reach."""
printer = await printer_factory(name="P", model="H2D")
mock_client = MagicMock()
mock_client.set_chamber_temperature.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
accepted = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=65")
assert accepted.status_code == 200
mock_client.set_chamber_temperature.assert_called_once_with(65)
rejected = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=66")
assert rejected.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
@@ -0,0 +1,66 @@
"""The chamber-temperature ceiling is shared by every surface that accepts one.
Reported on Discord: the preheat & heat-soak inputs capped at 60 °C, which put
the top of the H2 series' range (65 °C) out of reach. The ceiling now lives in
one place — ``MAX_CHAMBER_TEMP_C`` — and these tests pin both its value and the
fact that each schema actually derives its bound from it rather than carrying a
private literal that could drift back to 60.
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.print_queue import (
PrintQueueBulkUpdate,
PrintQueueItemCreate,
PrintQueueItemUpdate,
)
from backend.app.schemas.settings import AppSettingsUpdate
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# The H2 series (H2C / H2D / H2D Pro / H2S) and X2D heat the chamber to 65 °C.
# X1E stops at 60 and clamps in firmware. Hard-coded here on purpose: if the
# constant moves, that should be a deliberate edit, not a silent one.
EXPECTED_CEILING = 65
# (schema, kwargs the schema requires beyond the field under test)
OVERRIDE_SCHEMAS = [
(PrintQueueItemCreate, {}),
(PrintQueueItemUpdate, {}),
(PrintQueueBulkUpdate, {"item_ids": [1]}),
]
def test_ceiling_is_65():
assert MAX_CHAMBER_TEMP_C == EXPECTED_CEILING
@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
def test_override_accepts_the_ceiling(schema, required):
model = schema(preheat_chamber_target_override=MAX_CHAMBER_TEMP_C, **required)
assert model.preheat_chamber_target_override == MAX_CHAMBER_TEMP_C
@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
def test_override_rejects_above_the_ceiling(schema, required):
with pytest.raises(ValidationError):
schema(preheat_chamber_target_override=MAX_CHAMBER_TEMP_C + 1, **required)
@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
def test_override_still_accepts_zero(schema, required):
"""0 is "no chamber phase, even if the filament map wants one" — raising
the ceiling must not disturb the low end."""
model = schema(preheat_chamber_target_override=0, **required)
assert model.preheat_chamber_target_override == 0
def test_chamber_presets_accept_the_ceiling():
payload = f"[35, 45, {MAX_CHAMBER_TEMP_C}]"
assert AppSettingsUpdate(chamber_temp_presets=payload).chamber_temp_presets == payload
def test_chamber_presets_reject_above_the_ceiling():
with pytest.raises(ValidationError) as exc:
AppSettingsUpdate(chamber_temp_presets=f"[35, 45, {MAX_CHAMBER_TEMP_C + 1}]")
assert f"[0, {MAX_CHAMBER_TEMP_C}]" in str(exc.value)
@@ -9,7 +9,7 @@ from backend.app.schemas.settings import AppSettingsUpdate
PRESET_FIELDS = [
("nozzle_temp_presets", "[120, 220, 260]", 0, 320),
("bed_temp_presets", "[55, 75, 90]", 0, 140),
("chamber_temp_presets", "[35, 45, 60]", 0, 60),
("chamber_temp_presets", "[35, 45, 60]", 0, 65),
("fan_speed_presets", "[50, 75, 100]", 0, 100),
]
@@ -5,6 +5,7 @@ import {
parsePreheatFilamentTargets,
serializePreheatFilamentTargets,
} from '../utils/preheatFilamentTargets';
import { MAX_CHAMBER_TEMP_C } from '../utils/printer';
interface Props {
// JSON-encoded map; empty string means "use bundled defaults".
@@ -15,7 +16,7 @@ interface Props {
// Per-filament chamber target editor for Settings → Workflow → Preheat card
// (#1468). Renders one row per filament type with a numeric input clamped to
// 0-60 °C. Stripping back to the bundled defaults is handled by the parent
// 0-MAX_CHAMBER_TEMP_C. Stripping back to the bundled defaults is handled by the parent
// (Reset button next to the section title) — passing an empty string upward
// is the canonical "use defaults" signal, which keeps the editor stateless
// across resets.
@@ -24,7 +25,7 @@ export function PreheatFilamentTargetsEditor({ value, onChange, disabled = false
const map = parsePreheatFilamentTargets(value);
const updateOne = (key: string, next: number) => {
const clamped = Math.max(0, Math.min(60, Math.round(next)));
const clamped = Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, Math.round(next)));
const updated = { ...map, [key]: clamped };
onChange(serializePreheatFilamentTargets(updated));
};
@@ -45,7 +46,7 @@ export function PreheatFilamentTargetsEditor({ value, onChange, disabled = false
<input
type="number"
min={0}
max={60}
max={MAX_CHAMBER_TEMP_C}
step={1}
value={current}
onChange={(e) => updateOne(key, parseInt(e.target.value, 10) || 0)}
@@ -12,6 +12,7 @@ import {
CALIBRATION_MODE_ACTIVE,
CALIBRATION_MODE_INACTIVE,
} from '../../utils/calibrationMode';
import { MAX_CHAMBER_TEMP_C } from '../../utils/printer';
type OptionConfig = {
key: keyof PrintOptionsType;
@@ -79,7 +80,7 @@ export function PrintOptionsPanel({
if (Number.isNaN(parsed)) return;
onChange({
...options,
preheat_chamber_target_override: Math.max(0, Math.min(60, parsed)),
preheat_chamber_target_override: Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, parsed)),
});
};
@@ -188,7 +189,7 @@ export function PrintOptionsPanel({
<input
type="number"
min={0}
max={60}
max={MAX_CHAMBER_TEMP_C}
step={1}
value={options.preheat_chamber_target_override ?? ''}
onChange={(e) => handlePreheatTarget(e.target.value)}
+3 -3
View File
@@ -120,7 +120,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
import { PrintModal } from '../components/PrintModal';
import { PrinterInfoModal } from '../components/PrinterInfoModal';
import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
import { Collapsible } from '../components/Collapsible';
import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
@@ -4124,7 +4124,7 @@ function PrinterCard({
title="Set Chamber Temperature"
unit="°C"
customMin={0}
customMax={60}
customMax={MAX_CHAMBER_TEMP_C}
isPending={chamberTemperatureMutation.isPending}
options={buildPresetOptions(chamberTempPresets, 'C')}
onClose={() => setStatusControlMenu(null)}
@@ -7982,7 +7982,7 @@ export function PrintersPage() {
[settings?.bed_temp_presets],
);
const effectiveChamberTempPresets = useMemo(
() => parsePresetTriple(settings?.chamber_temp_presets, CHAMBER_TEMP_DEFAULTS, 0, 60),
() => parsePresetTriple(settings?.chamber_temp_presets, CHAMBER_TEMP_DEFAULTS, 0, MAX_CHAMBER_TEMP_C),
[settings?.chamber_temp_presets],
);
const effectiveFanSpeedPresets = useMemo(
+3 -1
View File
@@ -3,6 +3,8 @@
// are chamber-temperature recommendations from BambuStudio's bundled filament
// profiles; users can override the whole map via the Settings → Workflow card.
import { MAX_CHAMBER_TEMP_C } from './printer';
export const DEFAULT_PREHEAT_FILAMENT_TARGETS: Record<string, number> = {
PLA: 0,
PETG: 0,
@@ -44,7 +46,7 @@ export function parsePreheatFilamentTargets(raw: string): 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)));
out[key] = Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, Math.round(num)));
}
}
if (out.default === undefined) out.default = DEFAULT_PREHEAT_FILAMENT_TARGETS.default;
+8
View File
@@ -18,6 +18,14 @@ export function getPrinterImage(model: string | null | undefined): string {
return '/img/printers/default.png';
}
// Ceiling for every chamber-temperature target the UI accepts (manual set,
// preheat filament map, per-item preheat override, chamber quick-select
// presets). Mirrors backend MAX_CHAMBER_TEMP_C in
// backend/app/utils/printer_models.py — keep the two in sync. The H2 series
// (H2C / H2D / H2D Pro / H2S) and X2D heat the chamber to 65 °C; X1E tops out
// at 60 and its firmware clamps anything higher.
export const MAX_CHAMBER_TEMP_C = 65;
// G-code interchange families (#2578). Mirrors backend GCODE_COMPAT_FAMILIES
// in backend/app/utils/printer_models.py — keep the two in sync. A sliced 3MF
// may target a different model ONLY within its family; everything else is
+3 -1
View File
@@ -10,6 +10,8 @@
* is not part of the configurable triple.
*/
import { MAX_CHAMBER_TEMP_C } from './printer';
export type PresetTriple = readonly [number, number, number];
export const NOZZLE_TEMP_DEFAULTS: PresetTriple = [120, 220, 260];
@@ -28,7 +30,7 @@ export interface PresetCategory {
export const PRESET_CATEGORIES: readonly PresetCategory[] = [
{ key: 'nozzle_temp_presets', defaults: NOZZLE_TEMP_DEFAULTS, lo: 0, hi: 320, unit: 'C' },
{ key: 'bed_temp_presets', defaults: BED_TEMP_DEFAULTS, lo: 0, hi: 140, unit: 'C' },
{ key: 'chamber_temp_presets', defaults: CHAMBER_TEMP_DEFAULTS, lo: 0, hi: 60, unit: 'C' },
{ key: 'chamber_temp_presets', defaults: CHAMBER_TEMP_DEFAULTS, lo: 0, hi: MAX_CHAMBER_TEMP_C, unit: 'C' },
{ key: 'fan_speed_presets', defaults: FAN_SPEED_DEFAULTS, lo: 0, hi: 100, unit: '%' },
];
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DPZgvI9N.js"></script>
<script type="module" crossorigin src="/assets/index-DNwUdClD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
</head>
<body>