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