Просмотр исходного кода

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.
maziggy 1 месяц назад
Родитель
Сommit
b04664c64a

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **Chamber temperature can now be set up to 65 °C, not 60 (reported on Discord)** — Every field in Bambuddy that takes a chamber target stopped at 60 °C: the per-filament chamber map and the per-print chamber override in **Preheat & Heat Soak**, the chamber quick-select presets, and the chamber temperature control on the printer card. 60 is the ceiling for the X1E, which was the only heated-chamber model when that limit was written; the H2 series (H2C, H2D, H2D Pro, H2S) and the X2D heat to 65, so the top of their range was simply unreachable — an ABS or PA profile calling for 65 had to be run at 60. The ceiling is now 65 everywhere, held in one constant on each side rather than repeated as a literal at every call site, so the four surfaces cannot drift apart again. X1E owners are unaffected: its firmware clamps a higher request to its own maximum. Wiki updated. Covered by backend tests.
 - **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests.
 - **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests.
 - **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back.

+ 7 - 2
backend/app/api/routes/printers.py

@@ -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
backend/app/schemas/print_queue.py

@@ -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
backend/app/schemas/settings.py

@@ -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 - 0
backend/app/utils/printer_models.py

@@ -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:

+ 18 - 0
backend/tests/integration/test_printers_api.py

@@ -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):

+ 66 - 0
backend/tests/unit/test_chamber_temp_ceiling.py

@@ -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)

+ 1 - 1
backend/tests/unit/test_temperature_fan_presets.py

@@ -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),
 ]
 

+ 4 - 3
frontend/src/components/PreheatFilamentTargetsEditor.tsx

@@ -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)}

+ 3 - 2
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -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
frontend/src/pages/PrintersPage.tsx

@@ -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
frontend/src/utils/preheatFilamentTargets.ts

@@ -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 - 0
frontend/src/utils/printer.ts

@@ -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
frontend/src/utils/temperatureFanPresets.ts

@@ -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: '%' },
 ];
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DNwUdClD.js


+ 1 - 1
static/index.html

@@ -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>

Некоторые файлы не были показаны из-за большого количества измененных файлов