bambuddy/backend/app/models/virtual_printer.py
maziggy 4f2c073a34 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR.

The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.

So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.

Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.

A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.

The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.

Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.

Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
2026-07-31 16:16:39 +02:00

82 lines
4.3 KiB
Python

from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
# Canonical VP mode values. The legacy values `immediate` (→ archive) and
# `print_queue` (→ queue) shipped before the UI labels were aligned with the
# wire format. `normalize_vp_mode()` translates input from either form and
# the DB migration in `core/database.py` rewrites existing rows once at boot.
VP_MODE_ARCHIVE = "archive"
VP_MODE_REVIEW = "review"
VP_MODE_QUEUE = "queue"
VP_MODE_PROXY = "proxy"
VP_MODE_VALUES = (VP_MODE_ARCHIVE, VP_MODE_REVIEW, VP_MODE_QUEUE, VP_MODE_PROXY)
# Legacy → canonical map. Kept narrow on purpose so unrelated typos surface
# instead of getting silently re-pointed at a default.
_VP_MODE_ALIASES = {
"immediate": VP_MODE_ARCHIVE,
"print_queue": VP_MODE_QUEUE,
}
def normalize_vp_mode(value: str | None) -> str | None:
"""Map legacy wire values (`immediate`, `print_queue`) to canonical names.
Returns `None` unchanged so callers can decide whether to apply a default.
Returns unknown values unchanged so validators still see them and reject.
"""
if value is None:
return None
return _VP_MODE_ALIASES.get(value, value)
class VirtualPrinter(Base):
"""Virtual printer configuration for multi-instance support."""
__tablename__ = "virtual_printers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
mode: Mapped[str] = mapped_column(String(20), default=VP_MODE_ARCHIVE) # archive|review|queue|proxy
auto_dispatch: Mapped[bool] = mapped_column(Boolean, server_default="true") # queue mode: auto-start or manual
queue_force_color_match: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: pin per-slot type+color from the 3MF onto the queue
# item so the scheduler refuses to dispatch onto a printer with the wrong
# filament loaded (#1188).
save_ams_mapping: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
# `ams_mapping` field on the MQTT `project_file` command) instead of
# re-deriving one from the file's static type/color. Stamps it on the queue
# item so THIS print dispatches to those trays, and onto the archive's
# `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
# physical spools. Off by default: taking the slicer's pick makes the
# scheduler skip `_compute_ams_mapping_for_printer`, and with it
# `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
# inventory-remain overrides — so it stays opt-in per virtual printer
# rather than changing behaviour for upgraders (#2700).
gcode_injection: Mapped[bool] = mapped_column(
Boolean, server_default="false"
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
# injection (#1516). Default off so existing gcode_snippets users don't
# silently start injecting; no-op when no snippets exist for the model.
model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode)
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
target_printer_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
) # proxy mode
bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # dedicated IP (proxy mode)
remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # SSDP advertise IP
tailscale_disabled: Mapped[bool] = mapped_column(
Boolean, server_default="true"
) # opt-in: user must explicitly enable; auto-detect only runs then
serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001") # unique per printer
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())