mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
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:
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user