fix(vp): honor workflow default print options in queue-mode receive (#1235)

Prints sent from a slicer to a VP in print_queue mode arrived in the
  queue with bed_levelling / flow_cali / vibration_cali / layer_inspect /
  timelapse set to the SQLAlchemy column defaults, ignoring the user's
  workflow page settings entirely. The manual POST /print-queue endpoint
  reads these from the request body (frontend pulls them from settings
  before submitting), but manager._add_to_print_queue constructed the
  PrintQueueItem without touching any of those fields.

  Read default_bed_levelling and the other four settings via get_setting
  and pass them explicitly. _bool_setting helper handles the None ->
  AppSettings default fallback.
This commit is contained in:
maziggy
2026-05-12 08:59:14 +02:00
parent af52c4f2ff
commit ae43ced0ef
3 changed files with 157 additions and 0 deletions
+2
View File
@@ -8,6 +8,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.
### Fixed
- **Virtual-printer queue mode now honors workflow default print options** ([#1235](https://github.com/maziggy/bambuddy/issues/1235), reported by @jc21, root cause and patch by @jc21 in #1277) — Prints sent from Bambu Studio (or any slicer) to a VP in `print_queue` mode arrived in the queue with `bed_levelling`, `flow_cali`, `vibration_cali`, `layer_inspect`, and `timelapse` set to the SQLAlchemy column-level defaults, never the user's workflow preferences. The reporter happened to have every workflow default set to the opposite of the column defaults, so prints appeared to have all five options inverted; every queue item required hand-editing before dispatch. The manual `POST /print-queue/` endpoint reads these fields off the request body (the frontend pulls them from settings before submitting), but the VP-FTP-receive path at `backend/app/services/virtual_printer/manager.py:_add_to_print_queue` constructed `PrintQueueItem` without touching them at all — SQLAlchemy then filled in `bed_levelling=True, flow_cali=False, vibration_cali=True, layer_inspect=False, timelapse=False` regardless of what was in the DB. Fix reads `default_bed_levelling` / `default_flow_cali` / `default_vibration_cali` / `default_layer_inspect` / `default_timelapse` via the existing `get_setting()` helper (same pattern already used in the function for `virtual_printer_archive_name_source`) and passes them explicitly to `PrintQueueItem`. A small `_bool_setting()` helper maps `None → AppSettings schema default`, so a fresh install with no workflow page customization behaves identically to before. Regression tests: `test_add_to_print_queue_uses_workflow_defaults_from_settings` (verifies all five settings flow through with values opposite to the column defaults, matching the reporter's exact scenario) and `test_add_to_print_queue_falls_back_to_schema_defaults_when_unset` (verifies the no-DB-row path).
- **Linking a Spoolman spool to an AMS-HT slot no longer fails with a CHECK constraint error** ([#1274](https://github.com/maziggy/bambuddy/issues/1274), reported by guillaume.houba) — On H2C / H2D, AMS-HT units report `ams_id` 128+ (one ams_id per unit, single tray). The `spoolman_slot_assignments` table's `ck_ams_id_range` constraint only allowed 0-7 (standard AMS) or 255 (external), so the upsert on `POST /spoolman/inventory/slot-assignments` blew up with `IntegrityError: CHECK constraint failed: ck_ams_id_range` and the user had no way to link any spool to an AMS-HT slot. Widened the constraint formula to `(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255` — matches the value range the internal `spool_assignment` table already accepts and leaves room for up to 64 AMS-HT units (the existing `bambu_mqtt`/usage-tracker code uses the same 128-based addressing). Updated in the ORM model (`models/spoolman_slot_assignment.py`) and both the SQLite/Postgres `CREATE TABLE` DDL in `core/database.py`. New idempotent migration `_migrate_widen_spoolman_slot_ams_id_range`: Postgres path runs `DROP CONSTRAINT IF EXISTS` + `ADD CONSTRAINT` (no data risk — the new formula is strictly wider than the old); SQLite path detects the stale formula in `sqlite_master`, table-rebuilds via the standard `_v2` rename pattern used elsewhere in this file (`_migrate_update_auto_link_constraint` at `database.py:418`), and leaves pre-constraint legacy tables untouched. Tests: `test_ams_id_check_admits_ams_ht_range` (ORM + DDL formula) and `test_assign_accepts_ams_ht_id` (end-to-end `POST /slot-assignments` with `ams_id=128`).
- **X2D live camera stream no longer cut by Obico polling / snapshot capture** ([#1271](https://github.com/maziggy/bambuddy/issues/1271), reported by @clabeuhtegrite) — The MJPEG fan-out broadcaster from #1089 lets multiple browser viewers share one upstream RTSP socket per printer, but internal callers (Obico AI polling at the user's configured `obico_poll_interval`, and the manual `/camera/snapshot` endpoint) still opened their own fresh RTSP connections. X1C / H2D / P2S firmware tolerates brief concurrent camera sockets so the gap was invisible there. X2D firmware `01.01.00.00` (and likely future firmwares) enforces strict single-camera-connection more aggressively: every Obico poll (default every 5 s) kicked the live stream, the broadcaster paid the multi-second RTSP handshake to reconnect, and the user saw the stream cut "all the time." New helper `try_get_active_buffered_frame(printer_id)` at [`api/routes/camera.py:74`](backend/app/api/routes/camera.py) returns the broadcaster's last buffered frame (always <1 s old while any viewer is connected) and `None` when no viewer is active. Obico's `_capture_frame` and the `/camera/snapshot` endpoint check it first and only fall through to a fresh socket when no stream is running — preserving today's behavior when nobody is watching. `plate_detection` and `layer_timelapse` deliberately not converted: plate-detection needs guaranteed-fresh frames post-print (false-positive risk if the user already grabbed the print in the same second), and layer-timelapse is for external cameras only. Regression tests: `test_camera_snapshot_reuses_buffered_frame_when_stream_active` and two `TestCaptureFrameSharesBroadcasterUpstream` Obico tests.
@@ -345,6 +345,20 @@ class VirtualPrinterInstance:
async with self._session_factory() as db:
name_source = await get_setting(db, "virtual_printer_archive_name_source")
prefer_filename = name_source == "filename"
# Read workflow defaults from settings. Without this the
# PrintQueueItem below would fall back to the column-level
# defaults and ignore the user's workflow preferences (#1235).
# Fallbacks match AppSettings defaults in schemas/settings.py.
def _bool_setting(value: str | None, default: bool) -> bool:
return value.lower() == "true" if value is not None else default
bed_levelling = _bool_setting(await get_setting(db, "default_bed_levelling"), True)
flow_cali = _bool_setting(await get_setting(db, "default_flow_cali"), False)
vibration_cali = _bool_setting(await get_setting(db, "default_vibration_cali"), True)
layer_inspect = _bool_setting(await get_setting(db, "default_layer_inspect"), False)
timelapse = _bool_setting(await get_setting(db, "default_timelapse"), False)
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=None,
@@ -405,6 +419,11 @@ class VirtualPrinterInstance:
manual_start=not self.auto_dispatch,
required_filament_types=required_filament_types_json,
filament_overrides=filament_overrides_json,
bed_levelling=bed_levelling,
flow_cali=flow_cali,
vibration_cali=vibration_cali,
layer_inspect=layer_inspect,
timelapse=timelapse,
)
db.add(queue_item)
await db.commit()
@@ -317,6 +317,142 @@ class TestVirtualPrinterInstance:
queue_item = added_items[0]
assert queue_item.manual_start is True
@pytest.mark.asyncio
async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
"""#1235: VP queue-mode constructed PrintQueueItem without specifying
bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
so SQLAlchemy applied the column-level defaults and ignored the user's
workflow preferences entirely. Every print sent from the slicer to the
VP came through with the OPPOSITE of what the workflow page said,
forcing the user to edit each queue item by hand.
"""
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=22,
name="DefaultsTest",
mode="print_queue",
model="C12",
access_code="12345678",
serial_suffix="391800022",
auto_dispatch=True,
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
# The reporter set every workflow default to the OPPOSITE of the model's
# column default. Pre-fix the column defaults won; with the fix the
# 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
"default_vibration_cali": "false", # model default: True
"default_layer_inspect": "true", # model default: False
"default_timelapse": "true", # model default: False
}
async def fake_get_setting(_db, key):
return settings_map.get(key)
mock_archive = MagicMock()
mock_archive.id = 1
mock_archive.print_name = "test"
with (
patch(
"backend.app.api.routes.settings.get_setting",
new=fake_get_setting,
),
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 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.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"
@pytest.mark.asyncio
async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
"""#1235 fallback: when no workflow setting is in the DB, the queue
item should use the AppSettings (Pydantic) defaults — same values
the user sees in the workflow page on a fresh install.
"""
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=23,
name="FreshInstallDefaults",
mode="print_queue",
model="C12",
access_code="12345678",
serial_suffix="391800023",
auto_dispatch=True,
base_dir=tmp_path,
session_factory=mock_session_factory,
)
file_path = tmp_path / "test.3mf"
file_path.write_bytes(b"fake3mf")
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, # No settings → fall back to schema defaults
),
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]
# 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.vibration_cali is True
assert queue_item.layer_inspect is False
assert queue_item.timelapse is False
@pytest.mark.asyncio
async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
"""#1188: VP queue-mode used to create PrintQueueItems with no