feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration

Bed levelling, flow calibration, and nozzle-offset calibration were on/off
only, so the sole way to run bed levelling was to force a full level before
every print. Bambu Studio has always offered a third "Auto" state that lets
the printer skip the calibration when it was done recently -- the state most
users actually want. Make these three options tri-state (off/on/auto),
defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off
(Bambu Studio exposes no auto for those).

Wire encoding follows Bambu Studio's source exactly: each option sends a JSON
bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The
bool fields stay booleans (the #1478 H2S regression); only the companion int
widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays
queued when sending 2 is the auto contract (queued, skipped at runtime if
recent), not a broken "off".

- schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing
  legacy bool / 0-1 / true-false so old clients and un-migrated rows validate
- model + migration: boolean columns -> String; SQLite via column affinity +
  data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema
  (verified on both dialects); settings rows normalised true/false -> on/off
- MQTT: start_print takes the tri-state strings and emits the paired bool+int
- Virtual Printer: reconstructs the slicer's auto/on/off from the int companion
  (auto_bed_leveling / extrude_cali_flag) in both capture paths
- frontend: CalibrationMode type; off/auto/on segmented controls in the print
  dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings
  in all 11 locales
This commit is contained in:
maziggy
2026-07-20 17:55:56 +02:00
parent 454ce3ceec
commit 2e45893dd5
41 changed files with 817 additions and 343 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.5b2] - Unreleased
### Added
- **Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio** — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way **Off / Auto / On** choice, and new prints default to **Auto**. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick.
### Changed
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
+3 -3
View File
@@ -105,12 +105,12 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"print_drying_enabled",
"require_plate_clear",
"queue_shortest_first",
"default_bed_levelling",
"default_flow_cali",
# default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
# are tri-state strings (off/on/auto) — parsed via the raw-string else
# branch; the TriState validator coerces legacy "true"/"false" rows.
"default_vibration_cali",
"default_layer_inspect",
"default_timelapse",
"default_nozzle_offset_cali",
"ldap_enabled",
"ldap_auto_provision",
"local_login_enabled",
+54
View File
@@ -1442,6 +1442,60 @@ async def run_migrations(conn):
else:
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
# Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
# boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
# "auto" state for these (skip the calibration if it was done recently); our
# booleans could only send force-on / off. Legacy rows map true->'on',
# false->'off'; the new default is 'auto'. Idempotent on both dialects:
# SQLite leans on column affinity (a BOOLEAN-declared column stores text
# fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
# column type only while it is still boolean, so re-runs and fresh
# create_all() schemas (already VARCHAR) are skipped. Column names are
# hardcoded constants, not user input.
_tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
if is_sqlite():
for _col in _tristate_cols:
async with conn.begin_nested():
await conn.execute(
text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')")
)
await conn.execute(
text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')")
)
else:
for _col in _tristate_cols:
result = await conn.execute(
text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = 'print_queue' AND column_name = :col"
),
{"col": _col},
)
row = result.fetchone()
if row and row[0] == "boolean":
await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
await _safe_execute(
conn,
f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
)
await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
# Migration: normalise the workflow-default settings rows that back these
# options from legacy "true"/"false" to the tri-state vocabulary so the API
# returns real values (the AppSettings validator also coerces on read, but
# rewriting keeps the stored data honest). Only these three became tri-state.
for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
async with conn.begin_nested():
await conn.execute(
text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
{"k": _skey},
)
await conn.execute(
text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
{"k": _skey},
)
# Migration: Per-item preheat / heat-soak override (#1468). preheat_override
# is one of {inherit, on, off} — 'inherit' falls back to the global
# preheat_enabled setting; 'on' / 'off' force the decision. The chamber
+6 -4
View File
@@ -89,15 +89,17 @@ class PrintQueueItem(Base):
# true, the scheduler deletes the source row/files after archiving a copy.
cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
# Print options
bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
# Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
# strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
# The remaining three stay boolean (BambuStudio exposes no auto for them).
bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
# Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
# Preheat / heat-soak override (#1468). 'inherit' uses the global
# preheat_enabled setting; 'on' / 'off' force the per-item decision. The
+45 -17
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field, PlainSerializer, model_validator
from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
# Custom serializer to ensure UTC datetimes have Z suffix
@@ -15,6 +15,33 @@ def serialize_utc_datetime(dt: datetime | None) -> str | None:
UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
def _coerce_tristate(v: object) -> object:
"""Map legacy on/off booleans onto the tri-state calibration options.
bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
added BambuStudio's third "auto" state (skip if recently done). Rows and API
payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
coerce them so old clients and un-migrated rows still validate. getValueInt
parity: off=0, on=1, auto=2.
"""
if isinstance(v, bool):
return "on" if v else "off"
if isinstance(v, int):
return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
if isinstance(v, str):
low = v.strip().lower()
if low in ("true", "1"):
return "on"
if low in ("false", "0"):
return "off"
return v
# Tri-state calibration option: "auto" (printer decides / skip if recent),
# "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
@@ -39,17 +66,18 @@ class PrintQueueItemCreate(BaseModel):
ams_mapping: list[int] | None = None
# Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
plate_id: int | None = None
# Print options
bed_levelling: bool = True
flow_cali: bool = False
# Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
# (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
# layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Nozzle offset calibration — dual-nozzle printers only (#1682). Default True
# matches BambuStudio's default; the MQTT layer ignores the flag on
# single-nozzle printers so the wire value stays "skip" there.
nozzle_offset_cali: bool = True
# Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
# layer ignores the value on single-nozzle printers so the wire stays "skip".
nozzle_offset_cali: TriState = "auto"
# Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
# preheat_enabled setting; 'on' / 'off' force the decision. The chamber
# target falls through: this override → max(filament-map[loaded tray]) → 0.
@@ -83,13 +111,13 @@ class PrintQueueItemUpdate(BaseModel):
ams_mapping: list[int] | None = None
plate_id: int | None = None
# Print options
bed_levelling: bool | None = None
flow_cali: bool | None = None
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: 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)
# Auto-print G-code injection
@@ -126,13 +154,13 @@ class PrintQueueItemResponse(BaseModel):
ams_mapping: list[int] | None = None
plate_id: int | None = None # Plate ID for multi-plate 3MF files
# Print options
bed_levelling: bool = True
flow_cali: bool = False
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
nozzle_offset_cali: bool = True
nozzle_offset_cali: TriState = "auto"
preheat_override: Literal["inherit", "on", "off"] = "inherit"
preheat_chamber_target_override: int | None = None
status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
@@ -235,13 +263,13 @@ class PrintQueueBulkUpdate(BaseModel):
auto_off_after: bool | None = None
manual_start: bool | None = None
# Print options
bed_levelling: bool | None = None
flow_cali: bool | None = None
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: 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)
# Auto-print G-code injection
+11 -8
View File
@@ -2,6 +2,8 @@ import json
from pydantic import BaseModel, Field, field_validator
from backend.app.schemas.print_queue import TriState
class AppSettings(BaseModel):
"""Application settings schema."""
@@ -294,9 +296,10 @@ class AppSettings(BaseModel):
description="Enable user email notifications for print job events (requires Advanced Authentication)",
)
# Default print options
default_bed_levelling: bool = Field(default=True, description="Default bed levelling option for new prints")
default_flow_cali: bool = Field(default=False, description="Default flow calibration option for new prints")
# Default print options. bed_levelling / flow_cali / nozzle_offset_cali are
# tri-state (off/on/auto), defaulting to "auto" per BambuStudio.
default_bed_levelling: TriState = Field(default="auto", description="Default bed levelling option for new prints")
default_flow_cali: TriState = Field(default="auto", description="Default flow calibration option for new prints")
default_vibration_cali: bool = Field(
default=True, description="Default vibration calibration option for new prints"
)
@@ -304,8 +307,8 @@ class AppSettings(BaseModel):
default=False, description="Default first layer inspection option for new prints"
)
default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
default_nozzle_offset_cali: bool = Field(
default=True,
default_nozzle_offset_cali: TriState = Field(
default="auto",
description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
)
@@ -553,12 +556,12 @@ class AppSettingsUpdate(BaseModel):
low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
session_max_hours: int | None = Field(default=None, ge=1, le=720)
user_notifications_enabled: bool | None = None
default_bed_levelling: bool | None = None
default_flow_cali: bool | None = None
default_bed_levelling: TriState | None = None
default_flow_cali: TriState | None = None
default_vibration_cali: bool | None = None
default_layer_inspect: bool | None = None
default_timelapse: bool | None = None
default_nozzle_offset_cali: bool | None = None
default_nozzle_offset_cali: TriState | None = None
stagger_group_size: int | None = Field(default=None, ge=1, le=50)
stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
require_plate_clear: bool | None = None
+40 -26
View File
@@ -3842,13 +3842,13 @@ class BambuMQTTClient:
filename: str,
plate_id: int = 1,
ams_mapping: list[int] | None = None,
bed_levelling: bool = True,
flow_cali: bool = False,
bed_levelling: str = "auto",
flow_cali: str = "auto",
vibration_cali: bool = True,
layer_inspect: bool = False,
timelapse: bool = False,
use_ams: bool = True,
nozzle_offset_cali: bool = False,
nozzle_offset_cali: str = "auto",
nozzle_mapping: str | None = None,
):
"""Start a print job on the printer.
@@ -3861,12 +3861,13 @@ class BambuMQTTClient:
ams_mapping: List of tray IDs for each filament slot in the 3MF.
Global tray ID = (ams_id * 4) + slot_id, external = 254
timelapse: Record timelapse video
bed_levelling: Auto bed levelling before print
flow_cali: Flow/pressure advance calibration
bed_levelling: Bed levelling — tri-state "off"/"on"/"auto" (auto skips
if the bed was levelled recently, matching BambuStudio).
flow_cali: Flow/pressure advance calibration — "off"/"on"/"auto".
vibration_cali: Vibration compensation calibration
layer_inspect: First layer AI inspection
use_ams: Use AMS for automatic filament changes
nozzle_offset_cali: Run nozzle offset calibration before print
nozzle_offset_cali: Nozzle offset calibration — "off"/"on"/"auto"
(dual-nozzle printers only — silently ignored on single-nozzle).
nozzle_mapping: Opaque JSON string captured from BambuStudio's
project_file for H2C rack-swap (O1C2) (#1780). When non-null
@@ -4033,6 +4034,17 @@ class BambuMQTTClient:
# the archive even before the printer echoes subtask_id back (#1485).
self.last_dispatch_subtask_id = submission_id
# Tri-state calibration options → BambuStudio's getValueInt encoding:
# off=0 (never), on=1 (force every print), auto=2 (printer runs it
# only if it wasn't done recently). The paired bool field is true
# only for the explicit "on" state — for "auto" the bool is false and
# the int carries the intent, exactly as BambuStudio's SelectMachine
# sends it. Unknown values fall back to auto.
_tristate_wire = {"off": 0, "on": 1, "auto": 2}
bed_level_int = _tristate_wire.get(bed_levelling, 2)
flow_cali_int = _tristate_wire.get(flow_cali, 2)
nozzle_cali_int = _tristate_wire.get(nozzle_offset_cali, 2)
command = {
"print": {
"sequence_id": "20000",
@@ -4043,32 +4055,34 @@ class BambuMQTTClient:
"md5": "",
"bed_type": "auto",
"timelapse": timelapse,
"bed_leveling": bed_levelling,
"auto_bed_leveling": 1 if bed_levelling else 0,
"flow_cali": flow_cali,
# bed_leveling stays a JSON bool (true only for "on") and
# auto_bed_leveling carries the tri-state int — the exact
# two-field shape BambuStudio sends. The int must stay a plain
# number, never quoted (#1478 boolean-family concern applies to
# the *_cali bools, not these companion ints).
"bed_leveling": bed_levelling == "on",
"auto_bed_leveling": bed_level_int,
"flow_cali": flow_cali == "on",
"vibration_cali": vibration_cali,
"layer_inspect": layer_inspect,
"use_ams": use_ams,
"cfg": "0",
# extrude_cali_flag gates flow-dynamics calibration:
# 1 = run it, 0 = printer skips entirely (#1478 evidence).
# 2 = "skip and reuse stored PA" was previously believed to
# suppress the stage too, but #1721 testing on H2D 01.x
# showed stage 8 ("Calibrating dynamic flow") still gets
# queued when we send 2. A real BambuStudio Send-dialog
# capture today also showed 0 when the user disables flow
# calibration. Going with 0 to actually suppress the
# pre-print calibration stage.
"extrude_cali_flag": 1 if flow_cali else 0,
# 0 = never, 1 = force every print, 2 = auto (run only if the
# filament wasn't calibrated recently). #1721 saw stage 8
# ("Calibrating dynamic flow") still queued when we send 2 —
# that is exactly the auto contract (the printer queues the
# stage and skips it at runtime if recent), not a bug, so 2 is
# the right wire value for "auto". off/on remain 0/1.
"extrude_cali_flag": flow_cali_int,
"extrude_cali_manual_mode": 0,
# 1 = run, 0 = skip (matches BambuStudio's wire today). The
# earlier 2 = "skip" reading from #1682 didn't actually
# suppress stage 39 ("Nozzle offset calibration") on H2D
# 01.x — captured live in #1721. BambuStudio exposes the
# toggle only for dual-nozzle (H2D/H2D Pro/H2C/X2D); single-
# nozzle prints still resolve to 0 here so firmware never
# runs a calibration the head doesn't support.
"nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 0,
# 0 = never, 1 = force, 2 = auto (skip if recent). #1721 saw
# stage 39 ("Nozzle offset calibration") still queued on 2 —
# again the auto contract, not a failure to suppress.
# BambuStudio exposes the toggle only for dual-nozzle
# (H2D/H2D Pro/H2C/X2D); single-nozzle prints resolve to 0 so
# firmware never runs a calibration the head doesn't support.
"nozzle_offset_cali": nozzle_cali_int if is_dual_nozzle else 0,
"subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
"profile_id": "0",
"project_id": submission_id,
+3 -3
View File
@@ -704,13 +704,13 @@ class PrinterManager:
filename: str,
plate_id: int = 1,
ams_mapping: list[int] | None = None,
bed_levelling: bool = True,
flow_cali: bool = False,
bed_levelling: str = "auto",
flow_cali: str = "auto",
vibration_cali: bool = True,
layer_inspect: bool = False,
timelapse: bool = False,
use_ams: bool = True,
nozzle_offset_cali: bool = False,
nozzle_offset_cali: str = "auto",
nozzle_mapping: str | None = None,
) -> bool:
"""Start a print on a connected printer.
@@ -128,6 +128,31 @@ _SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
# scheduler tick interval before dispatch picks the item up.
_RECENT_QUEUE_ITEM_TTL = 30.0
# BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
# nozzle_offset_cali) travel on the project_file command as a bool plus an int
# companion — off=0, on=1, auto=2 (getValueInt parity). The int carries the full
# state; the bool is true only for "on".
_TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str | None:
"""Reconstruct off/on/auto from a captured slicer project_file dict.
Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
carries all three states; fall back to the bool field (on/off only); return
None when the slicer sent neither so the caller can use its own default.
"""
if int_field in data:
try:
resolved = _TRISTATE_INT.get(int(data[int_field]))
except (TypeError, ValueError):
resolved = None
if resolved is not None:
return resolved
if bool_field in data:
return "on" if bool(data[bool_field]) else "off"
return None
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
"""Get serial number for the given model and suffix."""
@@ -411,9 +436,16 @@ class VirtualPrinterInstance:
# `nozzles_info` is intentionally not stamped — column kept for
# legacy rows but never written; see PrintQueueItem.nozzles_info.
patch: dict = {}
# Tri-state options (off/on/auto) — reconstruct from the int companion.
for bool_field, int_field, column in (
("bed_leveling", "auto_bed_leveling", "bed_levelling"),
("flow_cali", "extrude_cali_flag", "flow_cali"),
):
resolved = _tristate_from_slicer(data, bool_field, int_field)
if resolved is not None:
patch[column] = resolved
# On/off options.
for mqtt_field, column in (
("bed_leveling", "bed_levelling"),
("flow_cali", "flow_cali"),
("vibration_cali", "vibration_cali"),
("layer_inspect", "layer_inspect"),
("timelapse", "timelapse"),
@@ -714,6 +746,19 @@ class VirtualPrinterInstance:
def _bool_setting(value: str | None, default: bool) -> bool:
return value.lower() == "true" if value is not None else default
def _tristate_setting(value: str | None, default: str) -> str:
"""Tri-state workflow default, coercing legacy true/false rows."""
if value is None:
return default
low = value.strip().lower()
if low in ("on", "off", "auto"):
return low
if low in ("true", "1"):
return "on"
if low in ("false", "0"):
return "off"
return default
def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
"""Slicer's MQTT value if present, else the settings default.
@@ -725,13 +770,27 @@ class VirtualPrinterInstance:
return bool(slicer_opts[field_mqtt])
return settings_default
def _slicer_tristate(bool_field: str, int_field: str, settings_default: str) -> str:
"""Slicer's tri-state (off/on/auto) if present, else the default."""
if slicer_opts is not None:
resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
if resolved is not None:
return resolved
return settings_default
# Note the MQTT field names differ from Bambuddy's column
# names: MQTT uses `bed_leveling` (single L) while the
# column / settings key use `bed_levelling` (double L).
bed_levelling = _slicer_or(
"bed_leveling", _bool_setting(await get_setting(db, "default_bed_levelling"), True)
bed_levelling = _slicer_tristate(
"bed_leveling",
"auto_bed_leveling",
_tristate_setting(await get_setting(db, "default_bed_levelling"), "auto"),
)
flow_cali = _slicer_tristate(
"flow_cali",
"extrude_cali_flag",
_tristate_setting(await get_setting(db, "default_flow_cali"), "auto"),
)
flow_cali = _slicer_or("flow_cali", _bool_setting(await get_setting(db, "default_flow_cali"), False))
vibration_cali = _slicer_or(
"vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
)
@@ -289,8 +289,8 @@ class TestPrintQueueAPI:
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"bed_levelling": False,
"flow_cali": True,
"bed_levelling": "off",
"flow_cali": "on",
"vibration_cali": False,
"layer_inspect": True,
"timelapse": True,
@@ -299,8 +299,8 @@ class TestPrintQueueAPI:
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["bed_levelling"] is False
assert result["flow_cali"] is True
assert result["bed_levelling"] == "off"
assert result["flow_cali"] == "on"
assert result["vibration_cali"] is False
assert result["layer_inspect"] is True
assert result["timelapse"] is True
@@ -324,13 +324,13 @@ class TestPrintQueueAPI:
response = await async_client.patch(
f"/api/v1/queue/{item.id}",
json={
"bed_levelling": False,
"bed_levelling": "off",
"timelapse": True,
},
)
assert response.status_code == 200
result = response.json()
assert result["bed_levelling"] is False
assert result["bed_levelling"] == "off"
assert result["timelapse"] is True
@pytest.mark.asyncio
@@ -929,7 +929,7 @@ class TestQueueLibraryFileSupport:
"library_file_id": lib_file.id,
"ams_mapping": [1, 2, -1, -1],
"plate_id": 2,
"bed_levelling": False,
"bed_levelling": "off",
"timelapse": True,
"manual_start": True,
}
@@ -939,7 +939,7 @@ class TestQueueLibraryFileSupport:
assert result["library_file_id"] == lib_file.id
assert result["ams_mapping"] == [1, 2, -1, -1]
assert result["plate_id"] == 2
assert result["bed_levelling"] is False
assert result["bed_levelling"] == "off"
assert result["timelapse"] is True
assert result["manual_start"] is True
@@ -1105,8 +1105,8 @@ class TestBulkUpdateEndpoint:
defaults = {
"status": "pending",
"position": 1,
"bed_levelling": True,
"flow_cali": False,
"bed_levelling": "on",
"flow_cali": "off",
"vibration_cali": True,
}
defaults.update(kwargs)
@@ -1123,12 +1123,12 @@ class TestBulkUpdateEndpoint:
@pytest.mark.integration
async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify bulk update can change a single field on multiple items."""
item1 = await queue_item_factory(bed_levelling=True)
item2 = await queue_item_factory(bed_levelling=True)
item1 = await queue_item_factory(bed_levelling="on")
item2 = await queue_item_factory(bed_levelling="on")
response = await async_client.patch(
"/api/v1/queue/bulk",
json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
json={"item_ids": [item1.id, item2.id], "bed_levelling": "off"},
)
assert response.status_code == 200
result = response.json()
@@ -1138,22 +1138,22 @@ class TestBulkUpdateEndpoint:
# Verify items were updated
await db_session.refresh(item1)
await db_session.refresh(item2)
assert item1.bed_levelling is False
assert item2.bed_levelling is False
assert item1.bed_levelling == "off"
assert item2.bed_levelling == "off"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify bulk update can change multiple fields at once."""
item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
item1 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
item2 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
response = await async_client.patch(
"/api/v1/queue/bulk",
json={
"item_ids": [item1.id, item2.id],
"bed_levelling": False,
"flow_cali": True,
"bed_levelling": "off",
"flow_cali": "on",
"manual_start": True,
},
)
@@ -1162,23 +1162,23 @@ class TestBulkUpdateEndpoint:
assert result["updated_count"] == 2
await db_session.refresh(item1)
assert item1.bed_levelling is False
assert item1.flow_cali is True
assert item1.bed_levelling == "off"
assert item1.flow_cali == "on"
assert item1.manual_start is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify bulk update skips non-pending items."""
pending_item = await queue_item_factory(status="pending", bed_levelling=True)
printing_item = await queue_item_factory(status="printing", bed_levelling=True)
completed_item = await queue_item_factory(status="completed", bed_levelling=True)
pending_item = await queue_item_factory(status="pending", bed_levelling="on")
printing_item = await queue_item_factory(status="printing", bed_levelling="on")
completed_item = await queue_item_factory(status="completed", bed_levelling="on")
response = await async_client.patch(
"/api/v1/queue/bulk",
json={
"item_ids": [pending_item.id, printing_item.id, completed_item.id],
"bed_levelling": False,
"bed_levelling": "off",
},
)
assert response.status_code == 200
@@ -1190,9 +1190,9 @@ class TestBulkUpdateEndpoint:
await db_session.refresh(pending_item)
await db_session.refresh(printing_item)
await db_session.refresh(completed_item)
assert pending_item.bed_levelling is False
assert printing_item.bed_levelling is True
assert completed_item.bed_levelling is True
assert pending_item.bed_levelling == "off"
assert printing_item.bed_levelling == "on"
assert completed_item.bed_levelling == "on"
@pytest.mark.asyncio
@pytest.mark.integration
@@ -2250,7 +2250,7 @@ class TestAbortedStatusNormalisation:
"printer_id": printer.id,
"archive_id": archive.id,
"quantity": 2,
"bed_levelling": False,
"bed_levelling": "off",
"timelapse": True,
}
response = await async_client.post("/api/v1/queue/", json=data)
@@ -2261,7 +2261,7 @@ class TestAbortedStatusNormalisation:
batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
assert len(batch_items) == 2
for item in batch_items:
assert item["bed_levelling"] is False
assert item["bed_levelling"] == "off"
assert item["timelapse"] is True
@pytest.mark.asyncio
+32 -14
View File
@@ -485,8 +485,9 @@ class TestSettingsAPI:
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["default_bed_levelling"] is True
assert result["default_flow_cali"] is False
# bed_levelling / flow_cali are tri-state, defaulting to "auto".
assert result["default_bed_levelling"] == "auto"
assert result["default_flow_cali"] == "auto"
assert result["default_vibration_cali"] is True
assert result["default_layer_inspect"] is False
assert result["default_timelapse"] is False
@@ -494,12 +495,12 @@ class TestSettingsAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_default_print_options(self, async_client: AsyncClient):
"""Verify default print options can be updated."""
"""Verify default print options can be updated (tri-state + booleans)."""
response = await async_client.put(
"/api/v1/settings/",
json={
"default_bed_levelling": False,
"default_flow_cali": True,
"default_bed_levelling": "off",
"default_flow_cali": "on",
"default_vibration_cali": False,
"default_layer_inspect": True,
"default_timelapse": True,
@@ -508,12 +509,29 @@ class TestSettingsAPI:
assert response.status_code == 200
result = response.json()
assert result["default_bed_levelling"] is False
assert result["default_flow_cali"] is True
assert result["default_bed_levelling"] == "off"
assert result["default_flow_cali"] == "on"
assert result["default_vibration_cali"] is False
assert result["default_layer_inspect"] is True
assert result["default_timelapse"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_default_print_options_legacy_bool_coerced(self, async_client: AsyncClient):
"""Old clients sending booleans for the tri-state options still work.
The TriState validator maps true->"on", false->"off" on input so a
pre-upgrade frontend never writes an invalid value.
"""
response = await async_client.put(
"/api/v1/settings/",
json={"default_bed_levelling": False, "default_flow_cali": True},
)
assert response.status_code == 200
result = response.json()
assert result["default_bed_levelling"] == "off"
assert result["default_flow_cali"] == "on"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_default_print_options_persist(self, async_client: AsyncClient):
@@ -521,14 +539,14 @@ class TestSettingsAPI:
await async_client.put(
"/api/v1/settings/",
json={
"default_bed_levelling": False,
"default_bed_levelling": "on",
"default_timelapse": True,
},
)
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert result["default_bed_levelling"] is False
assert result["default_bed_levelling"] == "on"
assert result["default_timelapse"] is True
@pytest.mark.asyncio
@@ -539,21 +557,21 @@ class TestSettingsAPI:
await async_client.put(
"/api/v1/settings/",
json={
"default_bed_levelling": False,
"default_flow_cali": True,
"default_bed_levelling": "off",
"default_flow_cali": "on",
},
)
# Update only one
response = await async_client.put(
"/api/v1/settings/",
json={"default_bed_levelling": True},
json={"default_bed_levelling": "auto"},
)
assert response.status_code == 200
result = response.json()
assert result["default_bed_levelling"] is True
assert result["default_flow_cali"] is True # Should remain from previous update
assert result["default_bed_levelling"] == "auto"
assert result["default_flow_cali"] == "on" # Should remain from previous update
# ========================================================================
# Home Assistant environment variable tests
@@ -55,8 +55,8 @@ async def printer_with_queue(db_session):
status="pending",
manual_start=True,
timelapse=True,
bed_levelling=True,
flow_cali=False,
bed_levelling="on",
flow_cali="off",
vibration_cali=True,
layer_inspect=False,
use_ams=True,
@@ -94,7 +94,7 @@ class TestWebhookStartPrint:
assert item.manual_start is False, "manual_start must be cleared so scheduler dispatches"
# Stored options must be untouched so the scheduler picks the user's choice.
assert item.timelapse is True
assert item.bed_levelling is True
assert item.bed_levelling == "on"
assert item.vibration_cali is True
@pytest.mark.asyncio
+69 -28
View File
@@ -4060,15 +4060,18 @@ class TestStartPrintAmsMapping:
mqtt_client.start_print(
"test.3mf",
timelapse=True,
bed_levelling=False,
flow_cali=True,
bed_levelling="off",
flow_cali="on",
vibration_cali=False,
layer_inspect=True,
)
cmd = self._get_published_command(mqtt_client)
assert cmd["timelapse"] is True
# bed_leveling stays a bool (true only for "on"); the tri-state rides on
# the auto_bed_leveling int.
assert cmd["bed_leveling"] is False
assert cmd["auto_bed_leveling"] == 0
assert cmd["flow_cali"] is True
assert cmd["vibration_cali"] is False
assert cmd["layer_inspect"] is True
@@ -4078,16 +4081,13 @@ class TestStartPrintAmsMapping:
def test_p2s_uses_boolean_format(self, mqtt_client):
"""P2S sends calibration fields as JSON booleans (single-nozzle, like X1C/A1/P1)."""
mqtt_client.model = "P2S"
mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
mqtt_client.start_print("test.3mf", timelapse=True, flow_cali="off")
cmd = self._get_published_command(mqtt_client)
assert cmd["timelapse"] is True
assert cmd["flow_cali"] is False
# flow_cali off → extrude_cali_flag=0 (firmware actually skips the
# pre-print calibration stage). #1721 test on H2D 01.x showed `2`
# didn't suppress stage 8 ("Calibrating dynamic flow") despite the
# earlier "skip and reuse stored PA" reading; `0` does — verified
# live against the stg queue.
# flow_cali "off" → extrude_cali_flag=0 (firmware skips the pre-print
# calibration stage entirely). "auto" would send 2 instead.
assert cmd["extrude_cali_flag"] == 0
def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
@@ -4132,8 +4132,8 @@ class TestStartPrintAmsMapping:
mqtt_client.start_print(
"test.3mf",
timelapse=True,
bed_levelling=False,
flow_cali=True,
bed_levelling="off",
flow_cali="on",
vibration_cali=False,
layer_inspect=True,
)
@@ -4148,13 +4148,48 @@ class TestStartPrintAmsMapping:
# flow-dynamics calibration instead of reusing the stored PA value.
assert cmd["extrude_cali_flag"] == 1
def test_nozzle_offset_cali_default_is_skip(self, mqtt_client):
"""Default `nozzle_offset_cali=False` → wire value `0` (skip).
def test_bed_leveling_auto_sends_int_two(self, mqtt_client):
"""`bed_levelling="auto"` → bool false + auto_bed_leveling=2.
#1721 H2D 01.x test: `2` ("skip") didn't actually suppress stage 39
("Nozzle offset calibration") — the stage stayed in the `stg` queue
and ran at print start. `0` does suppress it (verified live). Matches
what a BambuStudio Send-dialog echo on the same firmware shows.
Matches BambuStudio's ops_auto wire shape: the bool is true only for the
explicit "on" state; "auto" carries its intent in the int (2 = run only
if the bed wasn't levelled recently).
"""
mqtt_client.model = "X1C"
mqtt_client.start_print("test.3mf", bed_levelling="auto")
cmd = self._get_published_command(mqtt_client)
assert cmd["bed_leveling"] is False
assert cmd["auto_bed_leveling"] == 2
def test_bed_leveling_on_sends_int_one(self, mqtt_client):
"""`bed_levelling="on"` → bool true + auto_bed_leveling=1 (force)."""
mqtt_client.model = "X1C"
mqtt_client.start_print("test.3mf", bed_levelling="on")
cmd = self._get_published_command(mqtt_client)
assert cmd["bed_leveling"] is True
assert cmd["auto_bed_leveling"] == 1
def test_flow_cali_auto_sends_int_two(self, mqtt_client):
"""`flow_cali="auto"` → bool false + extrude_cali_flag=2.
#1721 saw stage 8 stay queued on 2 — that is the auto contract (queued,
skipped at runtime if the filament was calibrated recently), which is
exactly what "auto" should do.
"""
mqtt_client.model = "X1C"
mqtt_client.start_print("test.3mf", flow_cali="auto")
cmd = self._get_published_command(mqtt_client)
assert cmd["flow_cali"] is False
assert cmd["extrude_cali_flag"] == 2
def test_nozzle_offset_cali_default_auto_gated_on_single_nozzle(self, mqtt_client):
"""Default (auto) on a single-nozzle printer → wire value `0`.
The default is now "auto", but single-nozzle machines have no second
head to calibrate, so the MQTT layer gates any state to `0` there.
"""
mqtt_client.model = "P1S"
mqtt_client.start_print("test.3mf")
@@ -4163,44 +4198,50 @@ class TestStartPrintAmsMapping:
assert cmd["nozzle_offset_cali"] == 0
def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
"""Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped.
"""Single-nozzle printer: `nozzle_offset_cali="on"` is silently dropped.
H2S is in the H2 firmware family but single-nozzle. The toggle has
no physical meaning on single-nozzle machines and the UI gates it
behind `nozzle_count==2`. Even if a stale queue item from when the
printer was misidentified as dual carries the flag, the MQTT layer
must downgrade it so firmware never tries to calibrate a head it
doesn't have (#1682). `0` is the actually-honoured skip value
post-#1721; old `2` left the stage in the queue.
doesn't have (#1682).
"""
mqtt_client.model = "P1S"
mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
cmd = self._get_published_command(mqtt_client)
assert cmd["nozzle_offset_cali"] == 0
def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
"""Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`.
"""Dual-nozzle printer (H2D): `nozzle_offset_cali="on"` → wire value `1`.
H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
printer runs the nozzle-offset calibration pass before the print
starts. `1`=run (#1682).
starts. "on"=1 (force), "auto"=2, "off"=0 (#1682).
"""
mqtt_client.model = "H2D"
mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
cmd = self._get_published_command(mqtt_client)
assert cmd["nozzle_offset_cali"] == 1
def test_nozzle_offset_cali_false_on_dual_nozzle(self, mqtt_client):
"""Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `0` (skip).
def test_nozzle_offset_cali_auto_on_dual_nozzle(self, mqtt_client):
"""Dual-nozzle printer (H2D): `nozzle_offset_cali="auto"` → wire value `2`."""
mqtt_client.model = "H2D"
mqtt_client.start_print("test.3mf", nozzle_offset_cali="auto")
cmd = self._get_published_command(mqtt_client)
assert cmd["nozzle_offset_cali"] == 2
def test_nozzle_offset_cali_off_on_dual_nozzle(self, mqtt_client):
"""Dual-nozzle printer (H2D Pro): `nozzle_offset_cali="off"` → `0` (skip).
Critical for users like #1682 who run diamond nozzles and need to
keep the calibration off. The wire value flipped from `2` to `0` in
#1721 after the H2D test showed `2` didn't actually suppress.
keep the calibration off.
"""
mqtt_client.model = "H2D Pro"
mqtt_client.start_print("test.3mf", nozzle_offset_cali=False)
mqtt_client.start_print("test.3mf", nozzle_offset_cali="off")
cmd = self._get_published_command(mqtt_client)
assert cmd["nozzle_offset_cali"] == 0
@@ -374,12 +374,12 @@ class TestPrinterManager:
1,
ams_mapping=None,
timelapse=False,
bed_levelling=True,
flow_cali=False,
bed_levelling="auto",
flow_cali="auto",
vibration_cali=True,
layer_inspect=False,
use_ams=True,
nozzle_offset_cali=False,
nozzle_offset_cali="auto",
nozzle_mapping=None,
)
assert result is True
@@ -717,8 +717,9 @@ class TestVirtualPrinterInstance:
# settings values must flow through to the queue item exactly as stored.
settings_map = {
"virtual_printer_archive_name_source": None,
"default_bed_levelling": "false", # model default: True
"default_flow_cali": "true", # model default: False
# Legacy boolean-string rows still coerce (false->off, true->on).
"default_bed_levelling": "false", # tri-state default: auto
"default_flow_cali": "true", # tri-state default: auto
"default_vibration_cali": "false", # model default: True
"default_layer_inspect": "true", # model default: False
"default_timelapse": "true", # model default: False
@@ -746,8 +747,8 @@ class TestVirtualPrinterInstance:
assert len(added_items) == 1
queue_item = added_items[0]
assert queue_item.bed_levelling is False, "default_bed_levelling=false must flow through"
assert queue_item.flow_cali is True, "default_flow_cali=true must flow through"
assert queue_item.bed_levelling == "off", "default_bed_levelling=false must flow through"
assert queue_item.flow_cali == "on", "default_flow_cali=true must flow through"
assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
assert queue_item.timelapse is True, "default_timelapse=true must flow through"
@@ -806,8 +807,8 @@ class TestVirtualPrinterInstance:
assert len(added_items) == 1
queue_item = added_items[0]
# These must match the AppSettings (Pydantic) defaults in schemas/settings.py
assert queue_item.bed_levelling is True
assert queue_item.flow_cali is False
assert queue_item.bed_levelling == "auto"
assert queue_item.flow_cali == "auto"
assert queue_item.vibration_cali is True
assert queue_item.layer_inspect is False
assert queue_item.timelapse is False
@@ -896,8 +897,8 @@ class TestVirtualPrinterInstance:
assert len(added_items) == 1
queue_item = added_items[0]
assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
assert queue_item.flow_cali is True
assert queue_item.bed_levelling == "off", "Slicer's bed_leveling=False must override default_bed_levelling"
assert queue_item.flow_cali == "on"
assert queue_item.vibration_cali is False
assert queue_item.layer_inspect is True
# Capture is consumed — no lingering state for the next print of the same name.
@@ -962,8 +963,75 @@ class TestVirtualPrinterInstance:
assert len(added_items) == 1
queue_item = added_items[0]
assert queue_item.timelapse is True, "integer 1 must coerce to True"
assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
assert queue_item.flow_cali is True
assert queue_item.bed_levelling == "off", "integer 0 must coerce to off"
assert queue_item.flow_cali == "on"
@pytest.mark.asyncio
async def test_add_to_print_queue_captures_slicer_auto_from_int_companion(self, tmp_path):
"""The slicer's tri-state rides on the int companion (auto_bed_leveling /
extrude_cali_flag). When the slicer picks "Auto" it sends bed_leveling
false + auto_bed_leveling 2; the VP must record "auto", not "off".
"""
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
added_items = []
mock_db = AsyncMock()
mock_db.add = MagicMock(side_effect=added_items.append)
mock_db.commit = AsyncMock()
mock_session_factory = MagicMock()
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_factory.return_value = mock_session_ctx
inst = VirtualPrinterInstance(
vp_id=26,
name="SlicerAuto",
mode="queue",
model="C12",
access_code="12345678",
serial_suffix="391800026",
auto_dispatch=True,
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
await inst.on_print_command(
file_path.name,
{
"command": "project_file",
"bed_leveling": False,
"auto_bed_leveling": 2,
"flow_cali": False,
"extrude_cali_flag": 2,
},
)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new_callable=AsyncMock,
return_value=None,
),
patch(
"backend.app.services.archive.ArchiveService.archive_print",
new_callable=AsyncMock,
return_value=mock_archive,
),
):
await inst._add_to_print_queue(file_path, "192.168.1.100")
assert len(added_items) == 1
queue_item = added_items[0]
assert queue_item.bed_levelling == "auto", "auto_bed_leveling=2 must record 'auto'"
assert queue_item.flow_cali == "auto", "extrude_cali_flag=2 must record 'auto'"
@pytest.mark.asyncio
async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
@@ -1895,7 +1963,7 @@ class TestVirtualPrinterInstance:
params = dict(compiled.params)
assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
assert params["timelapse"] is True
assert params["bed_levelling"] is False # MQTT bed_leveling → column bed_levelling
assert params["bed_levelling"] == "off" # MQTT bed_leveling → column bed_levelling (tri-state)
# Recent-queue tracking dict is cleared after the patch.
assert file_path.name not in inst._recent_queue_items
@@ -83,13 +83,13 @@ async def queue_factory(tmp_path):
printer_id=printer.id,
archive_id=archive.id,
status=status,
bed_levelling=True,
flow_cali=False,
bed_levelling="on",
flow_cali="off",
vibration_cali=True,
layer_inspect=False,
timelapse=False,
use_ams=True,
nozzle_offset_cali=True,
nozzle_offset_cali="on",
)
db.add(item)
await db.commit()
@@ -77,13 +77,13 @@ async def queue_factory(tmp_path):
library_file_id=library_file.id,
status="pending",
cleanup_library_after_dispatch=cleanup,
bed_levelling=True,
flow_cali=False,
bed_levelling="on",
flow_cali="off",
vibration_cali=True,
layer_inspect=False,
timelapse=False,
use_ams=True,
nozzle_offset_cali=True,
nozzle_offset_cali="on",
)
db.add(item)
await db.commit()
@@ -158,13 +158,13 @@ async def archive_case(tmp_path):
printer_id=printer.id,
archive_id=archive.id,
status="pending",
bed_levelling=True,
flow_cali=False,
bed_levelling="on",
flow_cali="off",
vibration_cali=True,
layer_inspect=False,
timelapse=False,
use_ams=True,
nozzle_offset_cali=True,
nozzle_offset_cali="on",
)
db.add(item)
await db.commit()
+1
View File
@@ -142,6 +142,7 @@ function isAlwaysAllowedIdentical(value) {
// UI labels are identical in DE. List below curates the legitimate ones.
const DE_COGNATES = [
'{{ams}} · Slot {{slot}}', // #2587 runout slot label — "Slot" is the DE term too
'Auto', // calibrationMode_auto — German UI uses the loanword (matches BambuStudio DE)
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
@@ -39,8 +39,8 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
bed_levelling: 'on',
flow_cali: 'off',
vibration_cali: true,
layer_inspect: false,
timelapse: false,
@@ -24,8 +24,8 @@ const mockQueueItems = [
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
bed_levelling: 'on',
flow_cali: 'off',
vibration_cali: true,
layer_inspect: false,
timelapse: false,
@@ -51,8 +51,8 @@ const mockQueueItems = [
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
bed_levelling: 'on',
flow_cali: 'off',
vibration_cali: true,
layer_inspect: false,
timelapse: false,
@@ -78,8 +78,8 @@ const mockQueueItems = [
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
bed_levelling: 'on',
flow_cali: 'off',
vibration_cali: true,
layer_inspect: false,
timelapse: false,
+22 -15
View File
@@ -1154,6 +1154,13 @@ export interface APIKeyUpdate {
expires_at?: string | null;
}
/**
* Tri-state calibration option (BambuStudio parity): "off" never runs it,
* "on" forces it every print, "auto" lets the printer skip it if it was done
* recently. Used by bed_levelling, flow_cali, and nozzle_offset_cali.
*/
export type CalibrationMode = 'off' | 'on' | 'auto';
// Settings types
export interface AppSettings {
auto_archive: boolean;
@@ -1263,12 +1270,12 @@ export interface AppSettings {
// User email notifications toggle
user_notifications_enabled: boolean;
// Default print options
default_bed_levelling: boolean;
default_flow_cali: boolean;
default_bed_levelling: CalibrationMode;
default_flow_cali: CalibrationMode;
default_vibration_cali: boolean;
default_layer_inspect: boolean;
default_timelapse: boolean;
default_nozzle_offset_cali: boolean;
default_nozzle_offset_cali: CalibrationMode;
// Staggered batch start defaults
stagger_group_size: number;
stagger_interval_minutes: number;
@@ -2181,13 +2188,13 @@ export interface PrintQueueItem {
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
plate_id: number | null; // Plate ID for multi-plate 3MF files
// Print options
bed_levelling: boolean;
flow_cali: boolean;
bed_levelling: CalibrationMode;
flow_cali: CalibrationMode;
vibration_cali: boolean;
layer_inspect: boolean;
timelapse: boolean;
use_ams: boolean;
nozzle_offset_cali: boolean;
nozzle_offset_cali: CalibrationMode;
preheat_override: 'inherit' | 'on' | 'off';
preheat_chamber_target_override: number | null;
status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
@@ -2258,13 +2265,13 @@ export interface PrintQueueItemCreate {
ams_mapping?: number[] | null; // AMS slot mapping for multi-color prints
plate_id?: number | null; // Plate ID for multi-plate 3MF files
// Print options
bed_levelling?: boolean;
flow_cali?: boolean;
bed_levelling?: CalibrationMode;
flow_cali?: CalibrationMode;
vibration_cali?: boolean;
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
nozzle_offset_cali?: boolean;
nozzle_offset_cali?: CalibrationMode;
preheat_override?: 'inherit' | 'on' | 'off';
preheat_chamber_target_override?: number | null;
// Auto-print G-code injection
@@ -2302,13 +2309,13 @@ export interface PrintQueueItemUpdate {
ams_mapping?: number[];
plate_id?: number | null; // Plate ID for multi-plate 3MF files
// Print options
bed_levelling?: boolean;
flow_cali?: boolean;
bed_levelling?: CalibrationMode;
flow_cali?: CalibrationMode;
vibration_cali?: boolean;
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
nozzle_offset_cali?: boolean;
nozzle_offset_cali?: CalibrationMode;
preheat_override?: 'inherit' | 'on' | 'off';
preheat_chamber_target_override?: number | null;
// Auto-print G-code injection
@@ -2323,13 +2330,13 @@ export interface PrintQueueBulkUpdate {
auto_off_after?: boolean;
manual_start?: boolean;
// Print options
bed_levelling?: boolean;
flow_cali?: boolean;
bed_levelling?: CalibrationMode;
flow_cali?: CalibrationMode;
vibration_cali?: boolean;
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
nozzle_offset_cali?: boolean;
nozzle_offset_cali?: CalibrationMode;
preheat_override?: 'inherit' | 'on' | 'off';
preheat_chamber_target_override?: number | null;
// Auto-print G-code injection
@@ -1,15 +1,30 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronUp, Flame } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { PrintOptionsProps, PrintOptions as PrintOptionsType, PreheatOverride } from './types';
import type {
PrintOptionsProps,
PrintOptions as PrintOptionsType,
PreheatOverride,
CalibrationMode,
} from './types';
import {
CALIBRATION_MODES,
CALIBRATION_MODE_ACTIVE,
CALIBRATION_MODE_INACTIVE,
} from '../../utils/calibrationMode';
type OptionConfig = {
key: keyof PrintOptionsType;
label: string;
desc: string;
dualNozzleOnly?: boolean;
/** Tri-state (off/on/auto) rather than a plain on/off pair. */
tristate?: boolean;
};
// On/off options render as the same button pair, minus the "auto" choice.
const BOOLEAN_MODES = ['off', 'on'] as const;
/**
* Print options toggle panel with collapsible UI.
* Shows bed levelling, flow/vibration calibration, layer inspection, timelapse,
@@ -27,18 +42,22 @@ export function PrintOptionsPanel({
// Labels/descriptions reuse the settings.default* namespace — identical strings,
// already translated across all locales. Only nozzle_offset_cali is new (#1682).
const printOptionsConfig: OptionConfig[] = [
{ key: 'bed_levelling', label: t('settings.defaultBedLevelling'), desc: t('settings.defaultBedLevellingDesc') },
{ key: 'flow_cali', label: t('settings.defaultFlowCali'), desc: t('settings.defaultFlowCaliDesc') },
{ key: 'bed_levelling', label: t('settings.defaultBedLevelling'), desc: t('settings.defaultBedLevellingDesc'), tristate: true },
{ key: 'flow_cali', label: t('settings.defaultFlowCali'), desc: t('settings.defaultFlowCaliDesc'), tristate: true },
{ key: 'vibration_cali', label: t('settings.defaultVibrationCali'), desc: t('settings.defaultVibrationCaliDesc') },
{ key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
{ key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
{ key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true },
{ key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true, tristate: true },
];
const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);
const handleToggle = (key: keyof PrintOptionsType) => {
onChange({ ...options, [key]: !options[key] });
const handleToggle = (key: keyof PrintOptionsType, value: boolean) => {
onChange({ ...options, [key]: value });
};
const handleCalibrationMode = (key: keyof PrintOptionsType, mode: CalibrationMode) => {
onChange({ ...options, [key]: mode });
};
const handlePreheatOverride = (next: PreheatOverride) => {
@@ -81,26 +100,56 @@ export function PrintOptionsPanel({
</button>
{isExpanded && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
{visibleOptions.map(({ key, label, desc }) => (
<label key={key} className="flex items-center justify-between cursor-pointer group">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
{visibleOptions.map(({ key, label, desc, tristate }) =>
tristate ? (
<div key={key} className="flex items-center justify-between gap-3">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
</div>
<div className="flex gap-1 shrink-0">
{CALIBRATION_MODES.map((mode) => (
<button
key={mode}
type="button"
onClick={() => handleCalibrationMode(key, mode)}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
options[key as 'bed_levelling'] === mode
? CALIBRATION_MODE_ACTIVE[mode]
: CALIBRATION_MODE_INACTIVE
}`}
>
{t(`settings.calibrationMode_${mode}`)}
</button>
))}
</div>
</div>
<div
className={`relative w-10 h-5 rounded-full transition-colors ${
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 as 'bed_levelling'] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
) : (
<div key={key} className="flex items-center justify-between gap-3">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
</div>
<div className="flex gap-1 shrink-0">
{BOOLEAN_MODES.map((mode) => {
const active = (options[key as 'vibration_cali'] ? 'on' : 'off') === mode;
return (
<button
key={mode}
type="button"
onClick={() => handleToggle(key, mode === 'on')}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
active ? CALIBRATION_MODE_ACTIVE[mode] : CALIBRATION_MODE_INACTIVE
}`}
>
{t(`settings.calibrationMode_${mode}`)}
</button>
);
})}
</div>
</div>
</label>
))}
),
)}
{/* Preheat / heat-soak per-item override (#1468). Defaults to
'inherit' which means the global Settings → Workflow toggle
+9 -7
View File
@@ -1,4 +1,6 @@
import type { PrintQueueItem, Printer } from '../../api/client';
import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
export type { CalibrationMode };
/**
* Mode of operation for the PrintModal.
@@ -44,12 +46,12 @@ export interface PrintModalProps {
export type PreheatOverride = 'inherit' | 'on' | 'off';
export interface PrintOptions {
bed_levelling: boolean;
flow_cali: boolean;
bed_levelling: CalibrationMode;
flow_cali: CalibrationMode;
vibration_cali: boolean;
layer_inspect: boolean;
timelapse: boolean;
nozzle_offset_cali: boolean;
nozzle_offset_cali: CalibrationMode;
// 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
@@ -62,12 +64,12 @@ export interface PrintOptions {
* Default print options values.
*/
export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
bed_levelling: true,
flow_cali: false,
bed_levelling: 'auto',
flow_cali: 'auto',
vibration_cali: true,
layer_inspect: false,
timelapse: false,
nozzle_offset_cali: true,
nozzle_offset_cali: 'auto',
preheat_override: 'inherit',
preheat_chamber_target_override: null,
};
+3
View File
@@ -2106,6 +2106,9 @@ export default {
preheatOverride_inherit: 'Übernehmen',
preheatOverride_on: 'An',
preheatOverride_off: 'Aus',
calibrationMode_off: 'Aus',
calibrationMode_on: 'An',
calibrationMode_auto: 'Auto',
preheatTargetOverride: 'Kammer-Ziel überschreiben (°C, leer = Filament-Standard)',
plateClear: 'Druckplatte-Bestätigung',
requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
+3
View File
@@ -2125,6 +2125,9 @@ export default {
preheatOverride_inherit: 'Inherit',
preheatOverride_on: 'On',
preheatOverride_off: 'Off',
calibrationMode_off: 'Off',
calibrationMode_on: 'On',
calibrationMode_auto: 'Auto',
preheatTargetOverride: 'Chamber target override (°C, blank = filament default)',
plateClear: 'Plate-Clear Confirmation',
requirePlateClear: 'Require plate-clear confirmation',
+3
View File
@@ -2109,6 +2109,9 @@ export default {
preheatOverride_inherit: 'Heredar',
preheatOverride_on: 'Activado',
preheatOverride_off: 'Desactivado',
calibrationMode_off: 'Desactivado',
calibrationMode_on: 'Activado',
calibrationMode_auto: 'Automático',
preheatTargetOverride: 'Sobrescribir objetivo de cámara (°C, vacío = por filamento)',
plateClear: 'Confirmación de cama despejada',
requirePlateClear: 'Requerir confirmación de cama despejada',
+3
View File
@@ -2062,6 +2062,9 @@ export default {
preheatOverride_inherit: 'Hériter',
preheatOverride_on: 'Activé',
preheatOverride_off: 'Désactivé',
calibrationMode_off: 'Désactivé',
calibrationMode_on: 'Activé',
calibrationMode_auto: 'Auto',
preheatTargetOverride: 'Surcharger la cible chambre (°C, vide = par filament)',
plateClear: 'Confirmation de plateau libre',
requirePlateClear: 'Exiger la confirmation de plateau libre',
+3
View File
@@ -2062,6 +2062,9 @@ export default {
preheatOverride_inherit: 'Eredita',
preheatOverride_on: 'Attivo',
preheatOverride_off: 'Spento',
calibrationMode_off: 'Spento',
calibrationMode_on: 'Attivo',
calibrationMode_auto: 'Auto',
preheatTargetOverride: 'Sovrascrivi target camera (°C, vuoto = per filamento)',
plateClear: 'Conferma piatto libero',
requirePlateClear: 'Richiedi conferma piatto libero',
+3
View File
@@ -2105,6 +2105,9 @@ export default {
preheatOverride_inherit: '継承',
preheatOverride_on: 'オン',
preheatOverride_off: 'オフ',
calibrationMode_off: 'オフ',
calibrationMode_on: 'オン',
calibrationMode_auto: '自動',
preheatTargetOverride: 'チャンバー目標を上書き (°C、空欄でフィラメント既定値)',
plateClear: 'プレートクリア確認',
requirePlateClear: 'プレートクリア確認を必須にする',
+3
View File
@@ -1987,6 +1987,9 @@ export default {
preheatOverride_inherit: '상속',
preheatOverride_on: '켜기',
preheatOverride_off: '끄기',
calibrationMode_off: '끄기',
calibrationMode_on: '켜기',
calibrationMode_auto: '자동',
preheatTargetOverride: '챔버 목표 재정의 (°C, 비우면 필라멘트 기본값)',
plateClear: '플레이트 비움 확인',
requirePlateClear: '플레이트 비움 확인 필요',
+3
View File
@@ -2062,6 +2062,9 @@ export default {
preheatOverride_inherit: 'Herdar',
preheatOverride_on: 'Ligado',
preheatOverride_off: 'Desligado',
calibrationMode_off: 'Desligado',
calibrationMode_on: 'Ligado',
calibrationMode_auto: 'Auto',
preheatTargetOverride: 'Sobrescrever alvo da câmara (°C, vazio = por filamento)',
plateClear: 'Confirmação de placa livre',
requirePlateClear: 'Exigir confirmação de placa livre',
+3
View File
@@ -1987,6 +1987,9 @@ export default {
preheatOverride_inherit: "Наследовать",
preheatOverride_on: "Включено",
preheatOverride_off: "Выключено",
calibrationMode_off: "Выключено",
calibrationMode_on: "Включено",
calibrationMode_auto: "Авто",
preheatTargetOverride: "Переопределение температуры камеры (°C; пусто — значение для филамента)",
plateClear: "Подтверждение очистки пластины",
requirePlateClear: "Требовать подтверждение очистки пластины",
+3
View File
@@ -2110,6 +2110,9 @@ export default {
preheatOverride_inherit: 'Devral',
preheatOverride_on: 'Açık',
preheatOverride_off: 'Kapalı',
calibrationMode_off: 'Kapalı',
calibrationMode_on: 'Açık',
calibrationMode_auto: 'Otomatik',
preheatTargetOverride: 'Oda hedefini geçersiz kıl (°C, boş = filament varsayılanı)',
plateClear: 'Plaka Temizleme Onayı',
requirePlateClear: 'Plaka temizleme onayı gerektir',
+3
View File
@@ -2107,6 +2107,9 @@ export default {
preheatOverride_inherit: '继承',
preheatOverride_on: '开启',
preheatOverride_off: '关闭',
calibrationMode_off: '关闭',
calibrationMode_on: '开启',
calibrationMode_auto: '自动',
preheatTargetOverride: '覆盖腔体目标 (°C,留空使用耗材默认)',
plateClear: '热床清空确认',
requirePlateClear: '需要热床清空确认',
+3
View File
@@ -2107,6 +2107,9 @@ export default {
preheatOverride_inherit: '繼承',
preheatOverride_on: '開啟',
preheatOverride_off: '關閉',
calibrationMode_off: '關閉',
calibrationMode_on: '開啟',
calibrationMode_auto: '自動',
preheatTargetOverride: '覆寫腔體目標 (°C,留空使用耗材預設)',
plateClear: '熱床清空確認',
requirePlateClear: '需要熱床清空確認',
+44 -7
View File
@@ -65,7 +65,7 @@ import { api, ApiError } from '../api/client';
import { PipelineRunsView } from './PipelineRunsPage';
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
import { getBedTypeInfo } from '../utils/bedType';
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -143,13 +143,13 @@ function BulkEditModal({
const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');
const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');
const [bedLevelling, setBedLevelling] = useState<CalibrationMode | 'unchanged'>('unchanged');
const [flowCali, setFlowCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
const [nozzleOffsetCali, setNozzleOffsetCali] = useState<boolean | 'unchanged'>('unchanged');
const [nozzleOffsetCali, setNozzleOffsetCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
// Show the dual-nozzle-only toggle when the user has at least one
// dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle
@@ -229,14 +229,14 @@ function BulkEditModal({
<div>
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
<div className="space-y-2">
<TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
<TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
<CalibrationModeToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
<CalibrationModeToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
<TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
<TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
<TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
<TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
{hasDualNozzlePrinter && (
<TriStateToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
<CalibrationModeToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
)}
</div>
</div>
@@ -300,6 +300,43 @@ function TriStateToggle({
);
}
// Four-state selector for the tri-state calibration options in bulk edit
// (unchanged / off / auto / on). Mirrors TriStateToggle's chrome.
function CalibrationModeToggle({
label,
value,
onChange,
t,
}: {
label: string;
value: CalibrationMode | 'unchanged';
onChange: (val: CalibrationMode | 'unchanged') => void;
t: (key: string) => string;
}) {
const modes: Array<{ key: CalibrationMode | 'unchanged'; label: string; active: string }> = [
{ key: 'unchanged', label: '—', active: 'bg-bambu-dark-tertiary text-white' },
{ key: 'off', label: t('settings.calibrationMode_off'), active: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' },
{ key: 'auto', label: t('settings.calibrationMode_auto'), active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' },
{ key: 'on', label: t('settings.calibrationMode_on'), active: 'bg-bambu-green/20 text-bambu-green' },
];
return (
<div className="flex items-center justify-between py-1">
<span className="text-sm text-bambu-gray">{label}</span>
<div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
{modes.map(({ key, label: modeLabel, active }) => (
<button
key={key}
onClick={() => onChange(key)}
className={`px-2 py-1 text-xs rounded ${value === key ? active : 'text-bambu-gray hover:text-white'}`}
>
{modeLabel}
</button>
))}
</div>
</div>
);
}
// Sortable queue item for drag and drop
function SortableQueueItem({
item,
+53 -20
View File
@@ -9,8 +9,9 @@ import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import { checkPasswordComplexity } from '../utils/password';
import { fleetAudience, sponsorHref } from '../utils/fleetAudience';
import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
@@ -1010,12 +1011,12 @@ export function SettingsPage() {
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
settings.prometheus_token !== localSettings.prometheus_token ||
(settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
(settings.default_bed_levelling ?? true) !== (localSettings.default_bed_levelling ?? true) ||
(settings.default_flow_cali ?? false) !== (localSettings.default_flow_cali ?? false) ||
(settings.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
(settings.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
(settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
(settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(settings.default_nozzle_offset_cali ?? true) !== (localSettings.default_nozzle_offset_cali ?? true) ||
(settings.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
@@ -4213,29 +4214,61 @@ export function SettingsPage() {
{t('settings.defaultPrintOptionsDescription', 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.')}
</p>
{[
{ key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true, dualNozzleOnly: false },
{ key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false, dualNozzleOnly: false },
{ key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true, dualNozzleOnly: false },
{ key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false },
{ key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false },
{ key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true },
{ key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true, dualNozzleOnly: false, tristate: true },
{ key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false, dualNozzleOnly: false, tristate: true },
{ key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true, dualNozzleOnly: false, tristate: false },
{ key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false, tristate: false },
{ key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false, tristate: false },
{ key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true, tristate: true },
]
.filter(({ dualNozzleOnly }) => !dualNozzleOnly || (printers || []).some(p => p.nozzle_count === 2))
.map(({ key, label, desc, fallback }) => (
.map(({ key, label, desc, fallback, tristate }) => (
<div key={key} className="flex items-center justify-between">
<div className="flex-1 mr-4">
<p className="text-sm text-white">{label}</p>
<p className="text-xs text-bambu-gray mt-0.5">{desc}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings[key] ?? fallback}
onChange={(e) => updateSetting(key, e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
{tristate ? (
<div className="flex gap-1 shrink-0">
{CALIBRATION_MODES.map((mode) => {
const current = (localSettings[key] as CalibrationMode | undefined) ?? 'auto';
return (
<button
key={mode}
type="button"
onClick={() => updateSetting(key, mode)}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
current === mode
? CALIBRATION_MODE_ACTIVE[mode]
: CALIBRATION_MODE_INACTIVE
}`}
>
{t(`settings.calibrationMode_${mode}`)}
</button>
);
})}
</div>
) : (
<div className="flex gap-1 shrink-0">
{(['off', 'on'] as const).map((mode) => {
const current = ((localSettings[key] as boolean | undefined) ?? fallback) ? 'on' : 'off';
return (
<button
key={mode}
type="button"
onClick={() => updateSetting(key, mode === 'on')}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
current === mode
? CALIBRATION_MODE_ACTIVE[mode]
: CALIBRATION_MODE_INACTIVE
}`}
>
{t(`settings.calibrationMode_${mode}`)}
</button>
);
})}
</div>
)}
</div>
))}
</CardContent>
+19
View File
@@ -0,0 +1,19 @@
import type { CalibrationMode } from '../api/client';
/** Display order for the off/auto/on segmented controls. */
export const CALIBRATION_MODES: CalibrationMode[] = ['off', 'auto', 'on'];
/**
* Active-button classes per calibration mode. Each state gets its own colour so
* the selected value is legible at a glance rather than every choice reading as
* the same "on" green: Off = red (never), Auto = blue (printer decides),
* On = green (force every print).
*/
export const CALIBRATION_MODE_ACTIVE: Record<CalibrationMode, string> = {
off: 'bg-red-500 text-white',
auto: 'bg-blue-500 text-white',
on: 'bg-bambu-green text-white',
};
/** Inactive-button classes shared by every mode. */
export const CALIBRATION_MODE_INACTIVE = 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white';
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-DrPqYCte.js"></script>
<script type="module" crossorigin src="/assets/index-ZDL_bFQj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
</head>
<body>