manager.py 105 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160
  1. """Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
  2. Each virtual printer runs its own independent services (FTP, MQTT, SSDP, Bind)
  3. bound to its dedicated IP address, regardless of mode.
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. import time
  9. import uuid
  10. from collections.abc import Callable
  11. from datetime import datetime, timezone
  12. from pathlib import Path
  13. from typing import TYPE_CHECKING
  14. from backend.app.core.config import settings as app_settings
  15. from backend.app.models.virtual_printer import (
  16. VP_MODE_LIBRARY,
  17. VP_MODE_PROXY,
  18. VP_MODE_QUEUE,
  19. normalize_vp_mode,
  20. )
  21. from backend.app.services import slicer_ams_pick
  22. from backend.app.services.bambu_mqtt import resolve_external_spools_in_mapping
  23. from backend.app.services.virtual_printer.bind_server import BindServer
  24. from backend.app.services.virtual_printer.camera_bridge import CHAMBER, RTSP, AcceptedCode, CameraBridge
  25. from backend.app.services.virtual_printer.certificate import CertificateService
  26. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer, compute_passive_port_slice
  27. from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
  28. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  29. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
  30. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  31. from backend.app.utils.printer_models import has_ams3h, is_nozzle_rack_model
  32. from backend.app.utils.tray_ids import TrayLayout
  33. if TYPE_CHECKING:
  34. from backend.app.services.printer_manager import PrinterManager
  35. logger = logging.getLogger(__name__)
  36. # Mapping of SSDP model codes to display names
  37. # These are the codes that slicers expect during discovery
  38. # Sources:
  39. # - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  40. # - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
  41. VIRTUAL_PRINTER_MODELS = {
  42. # X1 Series
  43. "BL-P001": "X1C", # X1 Carbon
  44. "BL-P002": "X1", # X1
  45. "C13": "X1E", # X1E
  46. # X2 Series
  47. "N6": "X2D", # X2D
  48. # A2 Series (single-FDM + integrated cutter/plotter)
  49. "N9": "A2L", # A2L
  50. # P Series
  51. "C11": "P1P", # P1P
  52. "C12": "P1S", # P1S
  53. "N7": "P2S", # P2S
  54. # A1 Series
  55. "N2S": "A1", # A1
  56. "N1": "A1 Mini", # A1 Mini
  57. # H2 Series
  58. "O1D": "H2D", # H2D
  59. "O1E": "H2D Pro", # H2D Pro
  60. "O2D": "H2D Pro", # H2D Pro
  61. "O1C": "H2C", # H2C
  62. "O1C2": "H2C", # H2C (dual nozzle variant)
  63. "O1S": "H2S", # H2S
  64. }
  65. # Serial number prefixes for each model (based on Bambu Lab serial number format)
  66. # Format: MMM??RYMDDUUUUU (15 chars total)
  67. # MMM = Model prefix (3 chars)
  68. # ?? = Unknown/revision code (2 chars)
  69. # R = Revision letter (1 char)
  70. # Y = Year digit (1 char)
  71. # M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
  72. # DD = Day (2 chars)
  73. # UUUUU = Unit number (5 chars)
  74. MODEL_SERIAL_PREFIXES = {
  75. # X1 Series
  76. "BL-P001": "00M00A", # X1C
  77. "BL-P002": "00M00A", # X1
  78. "C13": "03W00A", # X1E
  79. # X2 Series
  80. "N6": "20P90A", # X2D (first 4 chars "20P9" match real serials)
  81. # A2 Series
  82. "N9": "26A19A", # A2L (first 5 chars "26A19" match real serials)
  83. # P Series
  84. "C11": "01S00A", # P1P
  85. "C12": "01P00A", # P1S
  86. "N7": "22E00A", # P2S
  87. # A1 Series
  88. "N2S": "03900A", # A1
  89. "N1": "03000A", # A1 Mini
  90. # H2 Series
  91. "O1D": "09400A", # H2D
  92. "O1E": "09400A", # H2D Pro (same prefix family as H2D)
  93. "O2D": "09400A", # H2D Pro
  94. "O1C": "09400A", # H2C, older units: they share the H2D's 094 prefix
  95. # H2C from 2026: real serials start 31B8B (31B model, 8B revision, then the
  96. # revision letter), e.g. 31B8BP... A virtual one in the H2D's 094 family
  97. # also got the H2D's HMS texts in the slicer.
  98. "O1C2": "31B8BP",
  99. "O1S": "09400A", # H2S
  100. }
  101. # Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)
  102. DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
  103. # Models whose display name covers more than one code, told apart by the
  104. # printer's serial: (first characters, code). The first match wins; no match
  105. # keeps the code the name maps to.
  106. _CODES_BY_SERIAL: dict[str, tuple[tuple[str, str], ...]] = {
  107. "H2C": (("31B", "O1C2"), ("094", "O1C")),
  108. }
  109. def model_code_for_printer(printer_model: str | None, serial: str | None = None) -> str | None:
  110. """The VP model code for a printer's model (display name or SSDP code) and serial.
  111. An H2C is an O1C or an O1C2, which only its serial tells: a slicer syncs
  112. with the virtual printer only when it reports the printer's own code.
  113. """
  114. if not printer_model:
  115. return None
  116. code = printer_model if printer_model in VIRTUAL_PRINTER_MODELS else DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
  117. name = VIRTUAL_PRINTER_MODELS.get(code or "")
  118. if name and serial:
  119. for prefix, by_serial in _CODES_BY_SERIAL.get(name, ()):
  120. if serial.upper().startswith(prefix):
  121. return by_serial
  122. return code
  123. # Default model
  124. DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
  125. # Bound on per-instance ``_slicer_print_options`` cache size. The slicer's
  126. # project_file MQTT command stashes one dict per filename; the
  127. # corresponding ``_add_to_print_queue`` pop only fires when the file
  128. # upload completes. Failed / cancelled / non-3MF uploads orphan their
  129. # stash. The bound triggers FIFO eviction in ``on_print_command`` once
  130. # the dict fills, so a long-running VP can't leak unbounded state.
  131. _SLICER_OPTIONS_CACHE_LIMIT = 128
  132. # How long ``_add_to_print_queue`` waits for the slicer's MQTT
  133. # ``project_file`` after the FTP upload completes (#1780 round 3).
  134. # Bambu Studio sends FTP first, then MQTT immediately after — but on
  135. # wireless / loaded setups the MQTT command can land 2+ s after FTP,
  136. # which used to time the wait out and silently drop ``nozzle_mapping``
  137. # + the other slicer-driven flags. The bumped window covers the
  138. # observed worst case in the field; the late-MQTT fallback in
  139. # ``on_print_command`` covers the rest.
  140. _SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
  141. # How long ``on_print_command`` will retroactively stamp slicer fields
  142. # onto a recently-committed queue item when the MQTT print command
  143. # arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers
  144. # extra-late MQTT (slow wireless slicer, NIC drop+retry) and the
  145. # scheduler tick interval before dispatch picks the item up.
  146. _RECENT_QUEUE_ITEM_TTL = 30.0
  147. # BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
  148. # nozzle_offset_cali) travel on the project_file command as an int — off=0,
  149. # on=1, auto=2 (getValueInt parity). Bed levelling and flow calibration add a
  150. # bool that is true only for "on"; nozzle offset calibration is the int alone.
  151. _TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
  152. def _tristate_from_slicer(data: dict, bool_field: str | None, int_field: str) -> str | None:
  153. """Reconstruct off/on/auto from a captured slicer project_file dict.
  154. Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
  155. carries all three states; fall back to the bool field (on/off only); return
  156. None when the slicer sent neither so the caller can use its own default.
  157. """
  158. if int_field in data:
  159. try:
  160. resolved = _TRISTATE_INT.get(int(data[int_field]))
  161. except (TypeError, ValueError):
  162. resolved = None
  163. if resolved is not None:
  164. return resolved
  165. if bool_field is not None and bool_field in data:
  166. return "on" if bool(data[bool_field]) else "off"
  167. return None
  168. def _extract_slicer_ams_mapping_json(
  169. data: dict, log_prefix: str, *, is_dual_nozzle: bool = False, layout: TrayLayout | None = None
  170. ) -> str | None:
  171. """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
  172. BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
  173. live, right before sending — either automatically or via the slicer's
  174. manual per-filament AMS-slot assignment dialog — and embeds the result as
  175. ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
  176. ID) directly in the MQTT ``project_file`` command. Confirmed by wire
  177. capture: the field is present and already in the exact shape
  178. ``PrintQueueItem.ams_mapping`` expects.
  179. The VP-queue path previously never read this — every queued print had the
  180. scheduler re-derive a mapping from just the 3MF's static type/color at
  181. dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
  182. the slicer's already-correct, live-resolved pick. That re-derivation can
  183. land on the wrong physical spool whenever the file's type+color match
  184. isn't unique (e.g. two spools of the same color) or the file's own
  185. filament-slot color wasn't what the user actually intended for that
  186. particular print. Capturing it here — mirroring the existing
  187. ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
  188. scheduler's "already resolved, don't touch it" branch in
  189. ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
  190. That branch skipping ``_compute_ams_mapping_for_printer`` is also what
  191. makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
  192. AMS-filament-backup gate (#1766), the inventory-remain overrides and the
  193. per-slot force-color overrides all live inside that function. Callers are
  194. responsible for the gating — this parser only says what the slicer sent.
  195. An external spool is ``-1`` in the flat list, the same as a filament with
  196. no tray, and is named only in ``ams_mapping2``. It is resolved from there
  197. to its global tray (254/255), as the usage capture does (#3166); dispatch
  198. turns that back into the external-spool entry. Kept as ``-1`` it dispatched
  199. as unmapped and the printer stopped with 0700-8012 (#3237).
  200. ``is_dual_nozzle`` is the target printer's, since that decides which
  201. global tray an external spool is. So is ``layout``: on a rack printer
  202. (H3C) each hotend has its own external input, 247-255.
  203. Returns ``None`` when the field is absent, unparsable, or the classic
  204. "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
  205. fresh live computation.
  206. """
  207. raw = data.get("ams_mapping")
  208. if raw is None:
  209. return None
  210. if isinstance(raw, str):
  211. try:
  212. raw = json.loads(raw)
  213. except json.JSONDecodeError:
  214. logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
  215. return None
  216. # bool is a subclass of int in Python — isinstance(True, int) is True —
  217. # so it must be excluded explicitly, or [True, False] would pass as a
  218. # valid mapping.
  219. if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
  220. return None
  221. detail = data.get("ams_mapping2")
  222. if isinstance(detail, str):
  223. try:
  224. detail = json.loads(detail)
  225. except json.JSONDecodeError:
  226. detail = None
  227. raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle, layout)
  228. if all(v < 0 for v in raw):
  229. # #2589 sentinel — every slot unresolved. Let the scheduler compute a
  230. # fresh mapping from live AMS state instead of trusting this.
  231. return None
  232. return json.dumps(raw)
  233. def _get_serial_for_model(model: str, serial_suffix: str) -> str:
  234. """Get serial number for the given model and suffix."""
  235. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  236. return f"{prefix}{serial_suffix}"
  237. class VirtualPrinterInstance:
  238. """Per-printer state and file handling logic.
  239. Each instance represents one virtual printer with its own config,
  240. upload directory, certificates, and file handling mode.
  241. """
  242. def __init__(
  243. self,
  244. *,
  245. vp_id: int,
  246. name: str,
  247. mode: str,
  248. model: str,
  249. access_code: str,
  250. serial_suffix: str,
  251. target_printer_ip: str = "",
  252. target_printer_serial: str = "",
  253. target_printer_id: int | None = None,
  254. auto_dispatch: bool = True,
  255. queue_force_color_match: bool = False,
  256. save_ams_mapping: bool = False,
  257. gcode_injection: bool = False,
  258. bind_ip: str = "",
  259. remote_interface_ip: str = "",
  260. base_dir: Path,
  261. session_factory: Callable | None = None,
  262. printer_manager: "PrinterManager | None" = None,
  263. ):
  264. self.id = vp_id
  265. self.name = name
  266. # Normalize on construction so the rest of the code only compares
  267. # canonical values, even when a legacy DB row hasn't been migrated
  268. # yet (e.g. fresh-from-disk during the boot window before the
  269. # one-shot migration in `core/database.py` has executed).
  270. self.mode = normalize_vp_mode(mode) or VP_MODE_LIBRARY
  271. self.model = model
  272. self.access_code = access_code
  273. self.serial_suffix = serial_suffix
  274. self.target_printer_ip = target_printer_ip
  275. self.target_printer_serial = target_printer_serial
  276. self.target_printer_id = target_printer_id
  277. self.auto_dispatch = auto_dispatch
  278. self.queue_force_color_match = queue_force_color_match
  279. self.save_ams_mapping = save_ams_mapping
  280. self.gcode_injection = gcode_injection
  281. self.bind_ip = bind_ip
  282. self.remote_interface_ip = remote_interface_ip
  283. self._session_factory = session_factory
  284. self._printer_manager = printer_manager
  285. # Directories
  286. self.upload_dir = base_dir / "uploads" / str(vp_id)
  287. self.cert_dir = base_dir / "certs" / str(vp_id)
  288. shared_ca_dir = base_dir / "certs"
  289. # Ensure directories exist
  290. self.upload_dir.mkdir(parents=True, exist_ok=True)
  291. (self.upload_dir / "cache").mkdir(exist_ok=True)
  292. self.cert_dir.mkdir(parents=True, exist_ok=True)
  293. # Certificate service (shared CA, per-instance printer cert)
  294. self._cert_service = CertificateService(
  295. cert_dir=self.cert_dir,
  296. serial=self.serial,
  297. shared_ca_dir=shared_ca_dir,
  298. )
  299. # Uploads received and not yet archived or queued; only counted, for
  300. # the status's pending_files.
  301. self._pending_files: dict[str, Path] = {}
  302. # Slicer-side print options captured from the MQTT `project_file`
  303. # command, keyed by filename. Used by `_add_to_print_queue` so the
  304. # queue item inherits the user's slicer-chosen timelapse / bed_leveling
  305. # / flow_cali / vibration_cali / layer_inspect / use_ams toggles rather
  306. # than falling back to the global `default_*` settings (#1403). FTP
  307. # completes a few hundred ms before the slicer's MQTT `project_file`
  308. # arrives, so the queue-add path waits briefly on the event below
  309. # before reading the dict. Events are popped along with the options
  310. # so the dict stays bounded.
  311. self._slicer_print_options: dict[str, dict] = {}
  312. self._slicer_print_options_events: dict[str, asyncio.Event] = {}
  313. # Queue items recently committed by `_add_to_print_queue`, keyed by
  314. # FTP filename. Used by `on_print_command` to retroactively stamp the
  315. # slicer's nozzle_mapping (and the other slicer-driven flags) onto a
  316. # queue item when the MQTT `project_file` arrives after the queue-add
  317. # wait timed out — the #1780 round-3 race. Value is
  318. # (queue_item_ids, monotonic_committed_at); entries older than
  319. # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
  320. # queue-add.
  321. self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
  322. # Per entry above: the highest slot id each queued plate prints, so a
  323. # late slicer AMS mapping is stamped only onto plates it reaches.
  324. self._recent_queue_item_reach: dict[str, dict[int, int]] = {}
  325. # Per-instance services
  326. self._proxy: SlicerProxyManager | None = None
  327. self._ftp: VirtualPrinterFTPServer | None = None
  328. self._mqtt: SimpleMQTTServer | None = None
  329. self._mqtt_bridge: MQTTBridge | None = None
  330. self._rtsp_proxy: CameraBridge | None = None
  331. self._bind: BindServer | None = None
  332. self._ssdp: VirtualPrinterSSDPServer | None = None
  333. self._ssdp_proxy: SSDPProxy | None = None
  334. self._tasks: list[asyncio.Task] = []
  335. # Pending timer that re-fires gcode_state=FINISH after a project_file
  336. # ack. See ``_schedule_finish_release`` for the #1658 rationale.
  337. self._finish_release_task: asyncio.Task | None = None
  338. @property
  339. def serial(self) -> str:
  340. """Full serial number for this virtual printer."""
  341. return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
  342. @property
  343. def cert_path(self) -> Path:
  344. return self._cert_service.cert_path
  345. @property
  346. def key_path(self) -> Path:
  347. return self._cert_service.key_path
  348. @property
  349. def is_proxy(self) -> bool:
  350. return self.mode == "proxy"
  351. @property
  352. def is_running(self) -> bool:
  353. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  354. def generate_certificates(self) -> tuple[Path, Path]:
  355. """Generate certificates for this instance."""
  356. self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
  357. additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
  358. if self.bind_ip:
  359. additional_ips = additional_ips or []
  360. additional_ips.append(self.bind_ip)
  361. self._cert_service.delete_printer_certificate()
  362. return self._cert_service.generate_certificates(additional_ips=additional_ips)
  363. # -- File handling callbacks --
  364. async def authenticate_user_code(self, code: str) -> int | None:
  365. """The user whose personal access code ``code`` is, if they may send here (#1430).
  366. Asked by the FTP and MQTT servers when a sign-in is not the VP's own
  367. code. Checked fresh at each sign-in; see ``user_codes.user_for_code``.
  368. """
  369. if not self._session_factory or self.is_proxy:
  370. return None
  371. from backend.app.services.virtual_printer.user_codes import user_for_code
  372. try:
  373. async with self._session_factory() as db:
  374. user = await user_for_code(db, code, mode=self.mode, target_printer_id=self.target_printer_id)
  375. except Exception as e: # noqa: BLE001 -- a lookup error refuses the sign-in, never crashes it
  376. logger.error("[VP %s] Personal access code check failed: %s", self.name, e)
  377. return None
  378. return user.id if user is not None else None
  379. async def user_may_control_target(self, user_id: int) -> bool:
  380. """Whether a personal-code user may send commands to the target printer now (#1430).
  381. Asked for every command the bridge would pass to the real printer for
  382. a connection that signed in with a personal code: sending files does
  383. not include stopping prints or sending G-code. Fresh each time, so it
  384. follows the user's account and permissions while the slicer stays on.
  385. """
  386. if not self._session_factory or self.target_printer_id is None:
  387. return False
  388. from backend.app.services.virtual_printer.user_codes import may_control_printer
  389. try:
  390. async with self._session_factory() as db:
  391. return await may_control_printer(db, user_id, self.target_printer_id)
  392. except Exception as e: # noqa: BLE001 -- a lookup error refuses the command
  393. logger.error("[VP %s] Printer control check failed: %s", self.name, e)
  394. return False
  395. def _target_access_code(self) -> str | None:
  396. """The target printer's own access code, which its camera checks."""
  397. if self._printer_manager is None or self.target_printer_id is None:
  398. return None
  399. client = self._printer_manager.get_client(self.target_printer_id)
  400. code = getattr(client, "access_code", None) if client is not None else None
  401. return code if isinstance(code, str) and code else None
  402. async def camera_codes(self) -> list[AcceptedCode]:
  403. """Codes the slicer's camera view through this VP accepts (#1430).
  404. The VP's own, the target printer's own, and the personal codes of
  405. users who may watch the target printer now. Asked at each camera
  406. connection.
  407. """
  408. accepted = [AcceptedCode(self.access_code)]
  409. printer_code = self._target_access_code()
  410. if printer_code and printer_code != self.access_code:
  411. accepted.append(AcceptedCode(printer_code))
  412. if self._session_factory is None or self.target_printer_id is None:
  413. return accepted
  414. from backend.app.services.virtual_printer.user_codes import camera_codes
  415. try:
  416. async with self._session_factory() as db:
  417. personal = await camera_codes(db, self.target_printer_id)
  418. except Exception as e: # noqa: BLE001 -- a lookup error leaves only the shared codes
  419. logger.error("[VP %s] Personal camera code lookup failed: %s", self.name, e)
  420. return accepted
  421. return accepted + [AcceptedCode(code, user_id) for code, user_id in personal]
  422. async def on_file_received(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
  423. """Handle file upload completion from FTP.
  424. ``user_id``: who sent it, when the slicer signed in with a personal
  425. access code (#1430); the File Manager file and queue jobs are theirs.
  426. """
  427. logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
  428. self._pending_files[file_path.name] = file_path
  429. # Accept both canonical (`library`/`queue`) and legacy
  430. # (`immediate`/`archive`/`print_queue`) wire values so a stale row that
  431. # hasn't been migrated yet still dispatches correctly. Migration in
  432. # `core/database.py` rewrites existing rows once at boot.
  433. mode = normalize_vp_mode(self.mode)
  434. if mode == VP_MODE_LIBRARY:
  435. await self._store_in_library(file_path, source_ip, user_id=user_id)
  436. elif mode == VP_MODE_QUEUE:
  437. await self._add_to_print_queue(file_path, source_ip, user_id=user_id)
  438. else:
  439. await self._queue_file(file_path, source_ip, user_id=user_id)
  440. # Signal job completion to the slicer. Send-flow slicers don't watch the
  441. # post-upload state and would be happy with anything; the Print flow
  442. # (intended for proxy-mode VPs, but users sometimes click it against
  443. # queue/immediate/review modes too — #1280) watches the gcode_state
  444. # cycle and only releases its in-flight-job lock when it sees FINISH.
  445. # Going PREPARE → IDLE wedges the slicer's UI at "Downloading...(0%)"
  446. # and blocks the next dispatch with "busy with another print job".
  447. # PREPARE → FINISH satisfies both flows. prepare_percent=100 also
  448. # unfreezes the slicer's "Downloading X%" progress bar which it ticks
  449. # against the same field during the upload window.
  450. if self._mqtt and file_path.suffix.lower() == ".3mf":
  451. self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
  452. # FINISH is the terminal state for the upload cycle per #1280
  453. # (commit 0d6171dc). The Print-flow slicer's in-flight-job lock
  454. # releases on FINISH; resetting to IDLE 2 s later would re-confuse
  455. # the slicer that just unwedged. Earlier audit suggesting the
  456. # IDLE reset was wrong — staying at FINISH is the designed
  457. # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
  458. def _keeps_slicer_nozzle_mapping(self) -> bool:
  459. """Whether the slicer's ``nozzle_mapping`` is stored on the queue item.
  460. On a rack model (H2C) it holds physical rack positions, which name the
  461. rack of the printer the slicer was looking at: kept only when this VP
  462. targets that one printer. A VP for any printer of the model leaves it
  463. empty, and the dispatch resolves the positions against the rack of the
  464. printer it picks. Elsewhere it holds extruder indices, which are the
  465. same on every printer of the model.
  466. """
  467. return self.target_printer_id is not None or not is_nozzle_rack_model(self.model)
  468. def _keeps_slicer_use_ams(self) -> bool:
  469. """Whether the slicer's ``use_ams`` is stored on the queue item.
  470. The slicer was looking at this VP, which advertises no AMS. Only a VP
  471. bound to one printer stands for a machine whose AMS the user chose to
  472. use or not; a VP for any printer of the model leaves it on and the
  473. dispatch reconciles it against the printer it picks (#2595).
  474. """
  475. return self.target_printer_id is not None
  476. def _target_is_dual_nozzle(self) -> bool:
  477. """Whether the target printer has two nozzles, which decides the global
  478. tray of an external spool in the slicer's mapping (#3237).
  479. Same signal dispatch uses: the live client's detection, else its model.
  480. Without a client, the VP's own model is what the slicer sliced for.
  481. """
  482. from backend.app.utils.printer_models import is_dual_nozzle_model
  483. client = (
  484. self._printer_manager.get_client(self.target_printer_id)
  485. if self._printer_manager is not None and self.target_printer_id is not None
  486. else None
  487. )
  488. if client is not None:
  489. return bool(getattr(client, "_is_dual_nozzle", False)) or is_dual_nozzle_model(
  490. getattr(client, "model", None)
  491. )
  492. return is_dual_nozzle_model(self.model)
  493. def _target_tray_layout(self) -> TrayLayout:
  494. """How the target printer numbers its trays, which places an H3C's
  495. per-hotend external inputs in the slicer's mapping.
  496. The target's live AMS data, else its model; without a target printer
  497. ("any available" queue mode), the VP's own model.
  498. """
  499. if self._printer_manager is not None and self.target_printer_id is not None:
  500. from backend.app.services.printer_manager import printer_tray_layout
  501. return printer_tray_layout(self.target_printer_id, self._printer_manager)
  502. return TrayLayout.ams3h() if has_ams3h(self.model) else TrayLayout()
  503. async def on_print_command(self, filename: str, data: dict) -> None:
  504. """Handle print command from MQTT.
  505. Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
  506. `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the
  507. H2C rack-pick `nozzle_mapping`) so the VP-queue path can inherit them
  508. when adding the item to the queue, rather than falling back to the
  509. global default settings (#1403, #1780).
  510. Only queue mode consumes the capture; archive / review / proxy
  511. modes ignore the print command, so we skip the stash there to keep
  512. the dict from accumulating one entry per print over the VP's
  513. uptime.
  514. Also schedules the #1658 follow-up that re-fires gcode_state=FINISH a
  515. moment after the synthetic project_file ack — for every non-proxy
  516. mode — so the slicer's "Downloading" UI releases on the slicer's
  517. FTP-first-then-MQTT send order.
  518. ``filename`` is the slicer's ``subtask_name`` (bare model name, no
  519. extension) — used verbatim for `_schedule_finish_release` because
  520. push_status echoes it back to the slicer as gcode_file / subtask_name.
  521. The queue-side stash key is derived from ``data["file"]`` (the FTP
  522. filename with extension) so `_add_to_print_queue`'s
  523. ``file_path.name`` lookup matches; falls back to ``filename`` when
  524. ``data["file"]`` is absent (legacy slicers / non-3MF uploads).
  525. Stash/lookup mismatch was the #1780 root cause — every captured field
  526. silently fell back to settings defaults on every Bambu Studio "Send".
  527. """
  528. logger.info("[VP %s] Print command for: %s", self.name, filename)
  529. mode = normalize_vp_mode(self.mode)
  530. if mode != VP_MODE_PROXY and filename and self._mqtt is not None:
  531. self._schedule_finish_release(filename)
  532. if mode != VP_MODE_QUEUE:
  533. return
  534. # Stash key must match `_add_to_print_queue`'s lookup, which uses
  535. # `file_path.name` (FTP filename WITH extension). The slicer's
  536. # `subtask_name` (== this method's `filename` arg) is the bare model
  537. # name, no extension — using it as the stash key was the #1780 root
  538. # cause.
  539. stash_key = data.get("file") or filename
  540. # Drop the oldest stash if the cache is growing — happens when the
  541. # slicer sends project_file for a filename whose FTP upload was
  542. # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
  543. # never fires. With no bound, a long-running VP accumulates one
  544. # dict per such mismatch.
  545. if len(self._slicer_print_options) >= _SLICER_OPTIONS_CACHE_LIMIT:
  546. try:
  547. stale_key = next(iter(self._slicer_print_options))
  548. self._slicer_print_options.pop(stale_key, None)
  549. self._slicer_print_options_events.pop(stale_key, None)
  550. logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
  551. except StopIteration:
  552. pass
  553. self._slicer_print_options[stash_key] = dict(data)
  554. event = self._slicer_print_options_events.get(stash_key)
  555. if event:
  556. event.set()
  557. return
  558. # No consumer waiting: `_add_to_print_queue` either already gave up
  559. # (wait_for timed out) or hasn't started yet (FTP still uploading).
  560. # If a queue item was committed within the last
  561. # `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds
  562. # settings defaults instead of the slicer's pick — retroactively
  563. # stamp the slicer-driven fields so the dispatcher honours the
  564. # user's choice. Covers the #1780 round-3 race where Bambu Studio's
  565. # MQTT lands just past the bumped wait ceiling.
  566. await self._restamp_recent_queue_item(stash_key, data)
  567. async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None:
  568. """Patch slicer-driven fields onto a queue item the MQTT command missed.
  569. ``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT``
  570. for the slicer's MQTT ``project_file`` before committing the queue
  571. item. If the MQTT command arrives after that window — observed in
  572. the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the
  573. row was already written with settings defaults. This method runs
  574. on the late MQTT path: it looks up the most recent queue items
  575. committed for this filename and patches in the slicer's
  576. ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
  577. while the items are still ``pending`` (scheduler hasn't dispatched
  578. them yet). The two mappings pass the same gates as in
  579. ``_add_to_print_queue``.
  580. """
  581. if not self._session_factory:
  582. return
  583. entry = self._recent_queue_items.get(stash_key)
  584. plate_reach = self._recent_queue_item_reach.pop(stash_key, {})
  585. if entry is None:
  586. return
  587. queue_item_ids, committed_at = entry
  588. if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL:
  589. self._recent_queue_items.pop(stash_key, None)
  590. return
  591. import json
  592. # Mirror the field set `_add_to_print_queue` reads off slicer_opts.
  593. # MQTT uses `bed_leveling` (single L); the column is `bed_levelling`.
  594. # `nozzles_info` is intentionally not stamped — column kept for
  595. # legacy rows but never written; see PrintQueueItem.nozzles_info.
  596. patch: dict = {}
  597. # Tri-state options (off/on/auto) — reconstruct from the int companion.
  598. for bool_field, int_field, column in (
  599. ("bed_leveling", "auto_bed_leveling", "bed_levelling"),
  600. ("flow_cali", "extrude_cali_flag", "flow_cali"),
  601. (None, "nozzle_offset_cali", "nozzle_offset_cali"),
  602. ):
  603. resolved = _tristate_from_slicer(data, bool_field, int_field)
  604. if resolved is not None:
  605. patch[column] = resolved
  606. # On/off options.
  607. for mqtt_field, column in (
  608. ("vibration_cali", "vibration_cali"),
  609. ("layer_inspect", "layer_inspect"),
  610. ("timelapse", "timelapse"),
  611. ("use_ams", "use_ams"),
  612. ):
  613. if mqtt_field == "use_ams" and not self._keeps_slicer_use_ams():
  614. continue
  615. if mqtt_field in data:
  616. patch[column] = bool(data[mqtt_field])
  617. # Same gate as in `_add_to_print_queue`.
  618. raw = data.get("nozzle_mapping") if self._keeps_slicer_nozzle_mapping() else None
  619. if raw is not None:
  620. if isinstance(raw, str):
  621. try:
  622. raw = json.loads(raw)
  623. except json.JSONDecodeError:
  624. logger.warning(
  625. "[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r",
  626. self.name,
  627. raw,
  628. )
  629. raw = None
  630. if raw is not None:
  631. patch["nozzle_mapping"] = json.dumps(raw)
  632. # Same two gates as the immediate path in `_add_to_print_queue`: a
  633. # model-based VP has no live AMS layout for the slicer to have resolved
  634. # tray IDs against, and taking the slicer's pick at all is the per-VP
  635. # `save_ams_mapping` opt-in (it makes the scheduler skip
  636. # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
  637. # #1766 backup gate).
  638. ams_mapping_json = (
  639. _extract_slicer_ams_mapping_json(
  640. data,
  641. f"[VP {self.name}] Late MQTT",
  642. is_dual_nozzle=self._target_is_dual_nozzle(),
  643. layout=self._target_tray_layout(),
  644. )
  645. if self.target_printer_id is not None and self.save_ams_mapping
  646. else None
  647. )
  648. # `Force color match` still wins for this dispatch — see the same
  649. # decision in `_add_to_print_queue`. The archive patch below is
  650. # deliberately not gated on it: persisting the pick for later reprints
  651. # is exactly what the toggle promises.
  652. if ams_mapping_json is not None and not self.queue_force_color_match:
  653. patch["ams_mapping"] = ams_mapping_json
  654. # `ams_mapping_json` alone is enough to keep going even when `patch` is
  655. # empty: with `Force color match` on it never reaches the queue item,
  656. # but it still has to be written onto the archive below.
  657. if not patch and ams_mapping_json is None:
  658. self._recent_queue_items.pop(stash_key, None)
  659. return
  660. from sqlalchemy import select, update
  661. from backend.app.models.archive import PrintArchive
  662. from backend.app.models.print_queue import PrintQueueItem
  663. try:
  664. async with self._session_factory() as db:
  665. # Only stamp items still pending and unclaimed; once the
  666. # scheduler has picked the row up we can't safely race the
  667. # dispatcher. A claim leaves the status "pending" while the
  668. # upload runs with the row's current mapping, so the claim is
  669. # checked too, and again in the writes below.
  670. unclaimed = (PrintQueueItem.status == "pending", PrintQueueItem.dispatching_at.is_(None))
  671. result = await db.execute(
  672. select(PrintQueueItem.id, PrintQueueItem.archive_id, PrintQueueItem.dispatching_at).where(
  673. PrintQueueItem.id.in_(queue_item_ids),
  674. PrintQueueItem.status == "pending",
  675. )
  676. )
  677. rows = result.all()
  678. eligible_ids = [row[0] for row in rows if row[2] is None]
  679. # A claimed job's archive still takes the slicer's pick below:
  680. # that copy serves later reprints, not this run.
  681. if not eligible_ids and (ams_mapping_json is None or not rows):
  682. self._recent_queue_items.pop(stash_key, None)
  683. return
  684. # As in `_add_to_print_queue`: a mapping shorter than a plate's
  685. # highest slot id cannot address that plate's own slots, so that
  686. # plate is left to the scheduler's computed mapping.
  687. short_ids: list[int] = []
  688. if "ams_mapping" in patch:
  689. reach = len(json.loads(patch["ams_mapping"]))
  690. short_ids = [i for i in eligible_ids if plate_reach.get(i, 0) > reach]
  691. if short_ids:
  692. logger.warning(
  693. "[VP %s] Late slicer ams_mapping has %d entries, too few for queue item(s) %s; "
  694. "leaving those to the scheduler's mapping.",
  695. self.name,
  696. reach,
  697. short_ids,
  698. )
  699. reached_ids = [i for i in eligible_ids if i not in short_ids]
  700. if patch and reached_ids:
  701. await db.execute(
  702. update(PrintQueueItem).where(PrintQueueItem.id.in_(reached_ids), *unclaimed).values(**patch)
  703. )
  704. short_patch = {k: v for k, v in patch.items() if k != "ams_mapping"}
  705. if short_ids and short_patch:
  706. await db.execute(
  707. update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids), *unclaimed).values(**short_patch)
  708. )
  709. # The queue items keep the pick for the archive their print
  710. # makes (a claimed one too: its archive may not exist yet).
  711. if ams_mapping_json is not None and rows:
  712. await db.execute(
  713. update(PrintQueueItem)
  714. .where(PrintQueueItem.id.in_([row[0] for row in rows]))
  715. .values(slicer_ams_mapping=slicer_ams_pick.pack(ams_mapping_json, self.target_printer_id))
  716. )
  717. # An item whose print already started has its archive, made
  718. # before this late MQTT arrived (with no slicer_ams_mapping) — see
  719. # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
  720. # too so a reprint later still picks up the slicer's pick, and
  721. # the "AMS mapping from slicer" badge reflects reality instead
  722. # of staying stuck on the archive's initial (empty) snapshot.
  723. # Already gated on `save_ams_mapping` above, and deliberately
  724. # NOT on `queue_force_color_match`: that toggle decides how
  725. # *this* print is matched, not whether the pick is worth
  726. # keeping for a later reprint.
  727. if ams_mapping_json is not None:
  728. archive_ids = {row[1] for row in rows if row[1] is not None}
  729. if archive_ids:
  730. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
  731. for archive in archive_result.scalars().all():
  732. extra = dict(archive.extra_data or {})
  733. extra["slicer_ams_mapping"] = {
  734. "mapping": json.loads(ams_mapping_json),
  735. "printer_id": self.target_printer_id,
  736. }
  737. archive.extra_data = extra
  738. await db.commit()
  739. logger.info(
  740. "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
  741. self.name,
  742. stash_key,
  743. sorted(patch.keys()),
  744. eligible_ids,
  745. " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
  746. )
  747. except Exception as e:
  748. logger.error(
  749. "[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s",
  750. self.name,
  751. queue_item_ids,
  752. stash_key,
  753. e,
  754. )
  755. finally:
  756. self._recent_queue_items.pop(stash_key, None)
  757. def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
  758. """Re-set gcode_state=FINISH on the VP after the project_file ack.
  759. #1280 set FINISH after the FTP upload completes — that was correct
  760. for the slicer flow at the time (MQTT project_file → FTP → done).
  761. Bambu Studio 2.7.x flipped the order to FTP → FTP → MQTT project_file,
  762. which means ``_send_print_response`` runs *after* the FINISH set in
  763. ``on_file_received`` and overwrites the state back to PREPARE. The
  764. slicer's 1 Hz status stream then carries PREPARE forever and the
  765. send modal sits at "Downloading" until the VP is restarted (#1658).
  766. Re-firing FINISH after a short delay closes the gap: the slicer sees
  767. the synthetic PREPARE in the project_file ack (and likely one PREPARE
  768. push on the 1 Hz cycle), then the next push carries FINISH and the
  769. modal releases. Proxy mode is exempt — there the real printer drives
  770. the state through the bridge and a synthetic FINISH would clobber a
  771. real PREPARE/RUNNING transition coming back from the printer.
  772. Cancels any in-flight timer before scheduling a new one so a slicer
  773. that fires project_file twice in quick succession only ends in one
  774. FINISH.
  775. """
  776. if self._mqtt is None:
  777. return
  778. if self._finish_release_task is not None and not self._finish_release_task.done():
  779. self._finish_release_task.cancel()
  780. self._finish_release_task = asyncio.create_task(
  781. self._delayed_finish_release(filename, delay),
  782. name=f"vp-{self.id}-finish-release",
  783. )
  784. async def _delayed_finish_release(self, filename: str, delay: float) -> None:
  785. """Sleep, then set gcode_state=FINISH. Used by ``_schedule_finish_release``."""
  786. try:
  787. await asyncio.sleep(delay)
  788. except asyncio.CancelledError:
  789. return
  790. if self._mqtt is None:
  791. return
  792. self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
  793. logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
  794. async def _store_in_library(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
  795. """Store the upload in the File Manager (library mode)."""
  796. if not self._session_factory:
  797. logger.error("Cannot store upload: no database session factory configured")
  798. self._pending_files.pop(file_path.name, None)
  799. return
  800. try:
  801. if file_path.suffix.lower() != ".3mf":
  802. logger.debug("Skipping non-3MF file: %s", file_path.name)
  803. return
  804. from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
  805. async with self._session_factory() as db:
  806. folder_id, _ = await upload_settings(db, self.id)
  807. library_file, reused = await store_upload(
  808. db, source_path=file_path, filename=file_path.name, folder_id=folder_id, owner_id=user_id
  809. )
  810. logger.info(
  811. "[VP %s] %s File Manager file %s (%s) from %s",
  812. self.name,
  813. "Same file as" if reused else "Stored as",
  814. library_file.id,
  815. library_file.filename,
  816. source_ip,
  817. )
  818. await self._broadcast_library_files_changed()
  819. except Exception as e:
  820. logger.error("[VP %s] Error storing %s in the File Manager: %s", self.name, file_path.name, e)
  821. finally:
  822. # Always drop it from the pending count and delete the temp file:
  823. # the File Manager holds its own copy, and a failed one must not
  824. # pile up in the upload dir (#audit-R2-1).
  825. self._pending_files.pop(file_path.name, None)
  826. try:
  827. file_path.unlink(missing_ok=True)
  828. except OSError:
  829. pass
  830. async def _queue_file(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
  831. """Queue file for user review."""
  832. if not self._session_factory:
  833. logger.error("Cannot queue: no database session factory configured")
  834. self._pending_files.pop(file_path.name, None)
  835. return
  836. if file_path.suffix.lower() != ".3mf":
  837. self._pending_files.pop(file_path.name, None)
  838. try:
  839. file_path.unlink()
  840. except OSError:
  841. pass
  842. return
  843. # Peek at the 3MF for the embedded title BEFORE we hand it off to the
  844. # DB. Storing it now means the /pending-uploads/ list doesn't have to
  845. # reopen every 3MF on every render to keep the review card and the
  846. # eventual archive name in sync (#1152 follow-up). Failure to parse is
  847. # not fatal — the response model falls back to the filename stem.
  848. metadata_print_name: str | None = None
  849. try:
  850. from backend.app.services.archive import ThreeMFParser
  851. parsed = ThreeMFParser(file_path).parse()
  852. raw_name = parsed.get("print_name")
  853. if isinstance(raw_name, str) and raw_name.strip():
  854. metadata_print_name = raw_name.strip()[:255]
  855. except Exception as e:
  856. logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
  857. # The upload waits for its review in a folder of its own: the slicer
  858. # sends the next file under the same name to the same place, which
  859. # would replace this one, so it would be approved with someone else's
  860. # content and owner (#1430).
  861. received_name = file_path.name
  862. waiting = (
  863. self.upload_dir / "pending" / uuid.uuid4().hex / received_name
  864. ) # SEC-PATH-OK: received_name = the FTP upload's Path(arg).name (a basename), folder a fresh uuid4
  865. try:
  866. waiting.parent.mkdir(parents=True, exist_ok=True)
  867. file_path.replace(waiting)
  868. file_path = waiting
  869. except OSError as e:
  870. logger.error("[VP %s] Could not keep %s for review: %s", self.name, received_name, e)
  871. self._pending_files.pop(received_name, None)
  872. file_path.unlink(missing_ok=True)
  873. return
  874. try:
  875. from backend.app.models.pending_upload import PendingUpload
  876. async with self._session_factory() as db:
  877. pending = PendingUpload(
  878. filename=file_path.name,
  879. file_path=str(file_path),
  880. file_size=file_path.stat().st_size,
  881. source_ip=source_ip,
  882. virtual_printer_id=self.id,
  883. uploaded_by_id=user_id,
  884. status="pending",
  885. uploaded_at=datetime.now(timezone.utc),
  886. metadata_print_name=metadata_print_name,
  887. )
  888. db.add(pending)
  889. await db.commit()
  890. logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
  891. except Exception as e:
  892. logger.error("Error queueing file: %s", e)
  893. # Queue insert failed — drop the temp file so it doesn't
  894. # accumulate. The file is unreachable without the DB row.
  895. try:
  896. file_path.unlink(missing_ok=True)
  897. except OSError:
  898. pass
  899. finally:
  900. # Always drop it from the pending count, also after a queue
  901. # failure.
  902. self._pending_files.pop(received_name, None)
  903. async def _add_to_print_queue(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
  904. """Store the file in the File Manager and queue it, for the target printer or model."""
  905. if not self._session_factory:
  906. logger.error("Cannot add to print queue: no database session factory configured")
  907. self._pending_files.pop(file_path.name, None)
  908. return
  909. if file_path.suffix.lower() != ".3mf":
  910. self._pending_files.pop(file_path.name, None)
  911. try:
  912. file_path.unlink()
  913. except OSError:
  914. pass
  915. return
  916. # Wait briefly for the slicer's MQTT `project_file` command so the
  917. # queue item can inherit the slicer-side print options the user
  918. # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
  919. # first and the MQTT command immediately after, so the typical lag
  920. # is a few hundred ms. The window is generous enough to absorb
  921. # wireless / loaded-Pi jitter without making every VP-queue add
  922. # visibly slow — observed worst case in #1780 round 3 was 2.085 s,
  923. # the previous 2.0 s ceiling. Falls back to the global default_*
  924. # settings if MQTT doesn't arrive in time (legacy behaviour for
  925. # users on a slicer that doesn't send a print command). #1403.
  926. # The wait is skipped when there's no MQTT server attached — covers
  927. # unit tests that invoke `_add_to_print_queue` directly without
  928. # going through `on_print_command`, so they don't pay the wait tax.
  929. slicer_opts = self._slicer_print_options.pop(file_path.name, None)
  930. if slicer_opts is None and self._mqtt is not None:
  931. event = asyncio.Event()
  932. self._slicer_print_options_events[file_path.name] = event
  933. try:
  934. await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
  935. slicer_opts = self._slicer_print_options.pop(file_path.name, None)
  936. except TimeoutError:
  937. slicer_opts = None
  938. finally:
  939. self._slicer_print_options_events.pop(file_path.name, None)
  940. # If the cache still misses, queued workflow flags / nozzle pick will
  941. # silently fall back to settings defaults. Surface the missed key so a
  942. # future stash/lookup mismatch (the #1780 root cause) is obvious in
  943. # the log instead of needing a wire capture to diagnose.
  944. if slicer_opts is None:
  945. logger.debug(
  946. "[VP %s] No slicer options cached for %r (cache keys: %s); "
  947. "workflow flags + nozzle pick will fall back to settings defaults.",
  948. self.name,
  949. file_path.name,
  950. sorted(self._slicer_print_options.keys()),
  951. )
  952. try:
  953. import json
  954. from backend.app.models.print_queue import PrintQueueItem
  955. from backend.app.services.filament_requirements import extract_filament_requirements
  956. from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
  957. from backend.app.services.print_defaults import default_print_options
  958. from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
  959. async with self._session_factory() as db:
  960. # Workflow defaults from settings. Without this the
  961. # PrintQueueItem below would fall back to the column-level
  962. # defaults and ignore the user's workflow preferences (#1235).
  963. # The slicer-side options captured above (if any) take
  964. # precedence per-field over these defaults.
  965. defaults = await default_print_options(db)
  966. def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
  967. """Slicer's MQTT value if present, else the settings default.
  968. Slicer payloads carry both bool and int (0/1) shapes
  969. depending on firmware family — coerce via bool() so
  970. `0`/`False` and `1`/`True` both work.
  971. """
  972. if slicer_opts is not None and field_mqtt in slicer_opts:
  973. return bool(slicer_opts[field_mqtt])
  974. return settings_default
  975. def _slicer_tristate(bool_field: str | None, int_field: str, settings_default: str) -> str:
  976. """Slicer's tri-state (off/on/auto) if present, else the default."""
  977. if slicer_opts is not None:
  978. resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
  979. if resolved is not None:
  980. return resolved
  981. return settings_default
  982. # Note the MQTT field names differ from Bambuddy's column
  983. # names: MQTT uses `bed_leveling` (single L) while the
  984. # column / settings key use `bed_levelling` (double L).
  985. bed_levelling = _slicer_tristate(
  986. "bed_leveling",
  987. "auto_bed_leveling",
  988. defaults["bed_levelling"],
  989. )
  990. flow_cali = _slicer_tristate(
  991. "flow_cali",
  992. "extrude_cali_flag",
  993. defaults["flow_cali"],
  994. )
  995. nozzle_offset_cali = _slicer_tristate(
  996. None,
  997. "nozzle_offset_cali",
  998. defaults["nozzle_offset_cali"],
  999. )
  1000. vibration_cali = _slicer_or("vibration_cali", defaults["vibration_cali"])
  1001. layer_inspect = _slicer_or("layer_inspect", defaults["layer_inspect"])
  1002. timelapse = _slicer_or("timelapse", defaults["timelapse"])
  1003. # No setting for it: the column default (on) when the slicer is
  1004. # silent, or when the VP does not stand for one printer.
  1005. use_ams = _slicer_or("use_ams", True) if self._keeps_slicer_use_ams() else True
  1006. # "Ask for Outcome" is a default print option like the ones
  1007. # above. A plate sent here from Bambu Studio is also one of the
  1008. # prints `confirm_outcome_external_prints` names — but it
  1009. # arrives with a queue item, so on_print_start resumes the
  1010. # archive its dispatch creates instead of treating it as external, and
  1011. # without this the setting could never reach it (#1898).
  1012. confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True)
  1013. # Sent with a personal access code (#1430): the jobs are that
  1014. # user's, and wait for a reviewer if their jobs must (#1620),
  1015. # as when they queue from Bambuddy itself.
  1016. review_required = False
  1017. if user_id is not None:
  1018. from sqlalchemy import select as _select
  1019. from sqlalchemy.orm import selectinload
  1020. from backend.app.core.auth import queue_review_required_for
  1021. from backend.app.models.user import User
  1022. sender = (
  1023. await db.execute(_select(User).where(User.id == user_id).options(selectinload(User.groups)))
  1024. ).scalar_one_or_none()
  1025. review_required = queue_review_required_for(sender)
  1026. # H2C dual-nozzle-rack slicer-pick preservation (#1780).
  1027. # BambuStudio's project_file MQTT command for rack-swap models
  1028. # (O1C2 today) carries `nozzle_mapping` — a per-filament array
  1029. # of physical nozzle position IDs (`list[int]`). Forward it
  1030. # verbatim onto the queue item so the dispatcher can replay it
  1031. # in its own project_file command. Without this the H2C
  1032. # firmware falls back to "last matching nozzle" auto-pick and
  1033. # ignores the user's Bambu Studio choice. Every other model
  1034. # has it absent from slicer_opts, so the capture is a
  1035. # transparent no-op there. (`nozzles_info` was also captured
  1036. # in the original fix but BambuStudio never actually sends it
  1037. # — verified via wire capture on H2C — so only `nozzle_mapping`
  1038. # is forwarded now.)
  1039. #
  1040. # On a rack model only for a VP that targets one printer: the
  1041. # positions name that printer's rack. An "Any <model>" VP leaves
  1042. # them to the dispatch, which resolves them against the rack of
  1043. # the printer it picks. See `_keeps_slicer_nozzle_mapping`.
  1044. nozzle_mapping_json: str | None = None
  1045. if slicer_opts is not None and self._keeps_slicer_nozzle_mapping():
  1046. raw = slicer_opts.get("nozzle_mapping")
  1047. if raw is not None:
  1048. # BambuStudio's NetworkAgent embeds this as parsed
  1049. # JSON in the project_file body (matching the
  1050. # ams_mapping shape Bambuddy already consumes as
  1051. # list[int]). Accept a JSON-encoded string defensively
  1052. # in case any path arrives stringified.
  1053. if isinstance(raw, str):
  1054. try:
  1055. raw = json.loads(raw)
  1056. except json.JSONDecodeError:
  1057. logger.warning(
  1058. "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r",
  1059. self.name,
  1060. raw,
  1061. )
  1062. raw = None
  1063. if raw is not None:
  1064. nozzle_mapping_json = json.dumps(raw)
  1065. # Slicer's own live-resolved AMS-slot pick (see docstring on
  1066. # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
  1067. # below, same treatment as nozzle_mapping_json above — when
  1068. # present it makes `_ensure_ams_mapping` skip its own
  1069. # type/color re-derivation entirely and dispatch use exactly
  1070. # the tray the slicer/user picked.
  1071. #
  1072. # Two gates, both required:
  1073. #
  1074. # 1. This VP must target one fixed printer. A model-based
  1075. # ("Any <model>") VP has no MQTT bridge to a real printer,
  1076. # so the slicer has no live AMS layout to resolve tray IDs
  1077. # against — whatever it sends here is meaningless (or,
  1078. # worse, coincidentally valid for the wrong printer once
  1079. # the scheduler later picks one).
  1080. # 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
  1081. # the slicer's pick means `_ensure_ams_mapping` returns
  1082. # early and `_compute_ams_mapping_for_printer` never runs —
  1083. # and that function is where `prefer_lowest_filament`, its
  1084. # AMS-filament-backup gate (#1766) and the inventory-remain
  1085. # overrides live. Honouring the slicer unconditionally would
  1086. # silently retire all of that for every existing queue-mode
  1087. # VP on upgrade, so it's opt-in like every other queue-mode
  1088. # behaviour toggle (#2700 review).
  1089. #
  1090. # Either gate failing leaves it unset, and the scheduler's
  1091. # normal type/color re-derivation runs against whichever
  1092. # printer actually gets the job.
  1093. ams_mapping_json: str | None = None
  1094. if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
  1095. ams_mapping_json = _extract_slicer_ams_mapping_json(
  1096. slicer_opts,
  1097. f"[VP {self.name}]",
  1098. is_dual_nozzle=self._target_is_dual_nozzle(),
  1099. layout=self._target_tray_layout(),
  1100. )
  1101. # `Force color match` is the user asking Bambuddy to do the
  1102. # matching strictly, against the printer's live trays. Its only
  1103. # effect on a fixed-printer item is via the per-slot
  1104. # `filament_overrides` written below, which are consumed inside
  1105. # `_compute_ams_mapping_for_printer` — the exact function a
  1106. # stored mapping skips. So when both toggles are on, the
  1107. # explicit strictness wins for *this* dispatch and the slicer's
  1108. # pick is still persisted onto the archive for later reprints,
  1109. # which is what `Save AMS mapping` actually promises (#2700
  1110. # review).
  1111. queue_ams_mapping_json = ams_mapping_json
  1112. if queue_ams_mapping_json is not None and self.queue_force_color_match:
  1113. logger.info(
  1114. "[VP %s] Kept the slicer's AMS pick for the archive but not onto the queue item(s): "
  1115. "'Force color match' is on, so the scheduler matches against live trays for this print.",
  1116. self.name,
  1117. )
  1118. queue_ams_mapping_json = None
  1119. # Parsed once for the per-plate length check in the loop below.
  1120. queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
  1121. # The file goes to the File Manager; the queue items print it
  1122. # from there, and the archive is made when one starts printing
  1123. # (an archive is a print that happened).
  1124. folder_id, remove_after_print = await upload_settings(db, self.id)
  1125. # A file its print removes must be this upload's own.
  1126. library_file, reused = await store_upload(
  1127. db,
  1128. source_path=file_path,
  1129. filename=file_path.name,
  1130. folder_id=folder_id,
  1131. owner_id=user_id,
  1132. reuse=not remove_after_print,
  1133. )
  1134. if library_file:
  1135. logger.info(
  1136. "[VP %s] %s File Manager file %s (%s)",
  1137. self.name,
  1138. "Same file as" if reused else "Stored as",
  1139. library_file.id,
  1140. library_file.filename,
  1141. )
  1142. # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
  1143. target_model = None
  1144. if not self.target_printer_id and self.model:
  1145. target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
  1146. # #1733: multi-plate "Send All" uploads ship every plate in
  1147. # one 3MF — `slice_info.config` lists each `<plate>` with
  1148. # its own index. Enqueue one PrintQueueItem per plate so
  1149. # the scheduler runs each separately. Single-plate "Send"
  1150. # comes through as `[N]` (one plate index) so the loop
  1151. # below runs once and the existing behaviour is preserved.
  1152. plate_ids = self._extract_plate_ids(file_path)
  1153. # Pick a base position the same way the manual /print-queue/
  1154. # POST does, then hand consecutive positions to each plate
  1155. # so a Send All keeps plate-order execution inside the
  1156. # queue (#1733). Previously hardcoded to 1, which created
  1157. # duplicate position=1 rows on every VP upload and made
  1158. # queue execution order non-deterministic for any non-
  1159. # empty queue.
  1160. from sqlalchemy import func, select as _sql_select
  1161. # One sequence across all pending items, not one per
  1162. # printer (#3200): the plates go to the end of the queue.
  1163. queue_scope = _sql_select(func.max(PrintQueueItem.position)).where(
  1164. PrintQueueItem.status == "pending"
  1165. )
  1166. try:
  1167. max_pos_raw = (await db.execute(queue_scope)).scalar()
  1168. max_pos = int(max_pos_raw) if max_pos_raw is not None else 0
  1169. except (TypeError, ValueError):
  1170. max_pos = 0
  1171. # Parse per-plate filament requirements (#1188). Each plate
  1172. # has its own filament set in `slice_info.config`, so the
  1173. # `required_filament_types` / `filament_overrides` columns
  1174. # on each queue item reflect THAT plate, not the file's
  1175. # first plate. Scoping was already plate-aware via #1697 —
  1176. # the `extract_filament_requirements(path, plate_id)` filter
  1177. # returns just the plate's filaments. required_filament_types
  1178. # is populated unconditionally — it's cheap, lets the
  1179. # scheduler reject obvious mis-matches even without
  1180. # force_color_match. filament_overrides only carries
  1181. # force_color_match=True when the per-VP setting is on, so
  1182. # upgraders keep the old behaviour by default.
  1183. queue_item_ids: list[int] = []
  1184. queue_item_reach: dict[int, int] = {}
  1185. for offset, plate_id in enumerate(plate_ids, start=1):
  1186. required_filament_types_json: str | None = None
  1187. filament_overrides_json: str | None = None
  1188. requirements = extract_filament_requirements(file_path, plate_id)
  1189. if requirements:
  1190. types = sorted({r["type"] for r in requirements if r.get("type")})
  1191. if types:
  1192. required_filament_types_json = json.dumps(types)
  1193. if self.queue_force_color_match:
  1194. # Carry tray_info_idx so force_color_match can
  1195. # tell Bambu PLA variants apart (#2650). Bambu
  1196. # reports Basic/Matte/Silk all as tray_type
  1197. # "PLA"; the variant lives only in tray_info_idx
  1198. # (GFA00/GFA01/GFA06/...). A blank idx (custom or
  1199. # third-party spool) means "no variant
  1200. # constraint" and the scheduler falls back to
  1201. # type+colour.
  1202. overrides = [
  1203. {
  1204. "slot_id": r["slot_id"],
  1205. "type": r.get("type", ""),
  1206. "color": r.get("color", ""),
  1207. "tray_info_idx": r.get("tray_info_idx", ""),
  1208. "force_color_match": True,
  1209. }
  1210. for r in requirements
  1211. if r.get("type") and r.get("color")
  1212. ]
  1213. if overrides:
  1214. filament_overrides_json = json.dumps(overrides)
  1215. # The slicer's mapping is indexed by the 3MF's own
  1216. # file-global slot ids (position = slot_id - 1), so one
  1217. # array covers every plate of a multi-plate Send All —
  1218. # each plate just reads the entries for the slots it
  1219. # actually prints. What must be checked is that it
  1220. # reaches that far: a mapping shorter than this plate's
  1221. # highest slot id can't address the plate's own slots,
  1222. # and `_ensure_ams_mapping` would keep it anyway
  1223. # because it only rejects an all-unresolved mapping. Fall
  1224. # back to a computed mapping for that plate instead
  1225. # (#2700 review).
  1226. plate_ams_mapping_json = queue_ams_mapping_json
  1227. plate_max_slot = max((r.get("slot_id") or 0) for r in requirements) if requirements else 0
  1228. if queue_ams_mapping is not None and requirements:
  1229. max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
  1230. if max_slot_id > len(queue_ams_mapping):
  1231. logger.warning(
  1232. "[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
  1233. "dropping it for this plate so the scheduler computes one from live AMS state.",
  1234. self.name,
  1235. len(queue_ams_mapping),
  1236. plate_id,
  1237. max_slot_id,
  1238. )
  1239. plate_ams_mapping_json = None
  1240. queue_item = PrintQueueItem(
  1241. printer_id=self.target_printer_id,
  1242. target_model=target_model,
  1243. library_file_id=library_file.id,
  1244. # Theirs when a personal access code signed in (#1430).
  1245. created_by_id=user_id,
  1246. # "Remove from File Manager once it prints": the
  1247. # dispatch moves the file into the archive.
  1248. cleanup_library_after_dispatch=remove_after_print,
  1249. plate_id=plate_id,
  1250. position=max_pos + offset,
  1251. status="pending",
  1252. manual_start=not self.auto_dispatch or review_required,
  1253. required_filament_types=required_filament_types_json,
  1254. filament_overrides=filament_overrides_json,
  1255. bed_levelling=bed_levelling,
  1256. flow_cali=flow_cali,
  1257. nozzle_offset_cali=nozzle_offset_cali,
  1258. vibration_cali=vibration_cali,
  1259. layer_inspect=layer_inspect,
  1260. timelapse=timelapse,
  1261. use_ams=use_ams,
  1262. confirm_outcome=confirm_outcome,
  1263. # Per-VP opt-in for auto-print G-code injection (#1516).
  1264. # Default off; when on, the scheduler still no-ops unless
  1265. # gcode_snippets are configured for the target model, so it's
  1266. # effectively "inject when enabled AND snippets exist".
  1267. gcode_injection=self.gcode_injection,
  1268. # H2C rack-swap slicer pick (#1780). Captured above;
  1269. # stamped on every plate so a multi-plate Send All keeps
  1270. # the same nozzle pick across plates rather than only the
  1271. # first one (mirrors the #1697 / #1188 per-plate loop fix).
  1272. nozzle_mapping=nozzle_mapping_json,
  1273. # Slicer's own live AMS-slot pick, when present —
  1274. # see `_extract_slicer_ams_mapping_json`.
  1275. ams_mapping=plate_ams_mapping_json,
  1276. # The slicer's pick for the archive this print makes,
  1277. # so a reprint can reuse it; set with "Force color
  1278. # match" too, which keeps it off ams_mapping above.
  1279. # Already gated on `save_ams_mapping` and a target.
  1280. slicer_ams_mapping=slicer_ams_pick.pack(ams_mapping_json, self.target_printer_id),
  1281. )
  1282. db.add(queue_item)
  1283. await db.flush() # populate queue_item.id before logging
  1284. queue_item_ids.append(queue_item.id)
  1285. queue_item_reach[queue_item.id] = plate_max_slot
  1286. await db.commit()
  1287. # Track the freshly-committed queue items so
  1288. # `on_print_command` can retroactively stamp slicer-side
  1289. # fields if the MQTT `project_file` lands AFTER the
  1290. # `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the
  1291. # #1780 round-3 race. Eviction of stale entries here
  1292. # keeps the dict bounded; the queue path is the only
  1293. # writer, so doing it on commit is enough.
  1294. now = time.monotonic()
  1295. cutoff = now - _RECENT_QUEUE_ITEM_TTL
  1296. self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
  1297. self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
  1298. self._recent_queue_item_reach = {
  1299. k: v for k, v in self._recent_queue_item_reach.items() if k in self._recent_queue_items
  1300. }
  1301. self._recent_queue_item_reach[file_path.name] = queue_item_reach
  1302. # Last-chance check: MQTT for this filename could have
  1303. # arrived during ANY await between the initial pop and
  1304. # now — wait_for itself, archive_print, db.flush,
  1305. # db.commit. In all those cases `on_print_command`
  1306. # stashed its data but neither the event-signal path nor
  1307. # the retroactive `_recent_queue_items` path was in
  1308. # place to consume it. Pop any late stash and apply
  1309. # inline so the late MQTT never leaks past the queue-add.
  1310. late_opts = self._slicer_print_options.pop(file_path.name, None)
  1311. if late_opts is not None:
  1312. logger.info(
  1313. "[VP %s] Late slicer MQTT detected for %s during queue-add — "
  1314. "applying inline (race vs commit/archive/flush yield)",
  1315. self.name,
  1316. file_path.name,
  1317. )
  1318. await self._restamp_recent_queue_item(file_path.name, late_opts)
  1319. if len(queue_item_ids) == 1:
  1320. logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
  1321. else:
  1322. logger.info(
  1323. "[VP %s] Added %d queue items for multi-plate upload (plates %s): %s",
  1324. self.name,
  1325. len(queue_item_ids),
  1326. plate_ids,
  1327. queue_item_ids,
  1328. )
  1329. await self._broadcast_library_files_changed()
  1330. from backend.app.services.queue_announcements import announce_queue_jobs_added
  1331. await announce_queue_jobs_added(db, queue_item_ids)
  1332. except Exception as e:
  1333. logger.error("Error adding to print queue: %s", e)
  1334. finally:
  1335. # Always release the marker and clean the temp file. Without this
  1336. # the same-name STOR guard would block the next upload and the
  1337. # upload_dir would accumulate failed temp files forever
  1338. # (#audit-R2-1).
  1339. self._pending_files.pop(file_path.name, None)
  1340. try:
  1341. file_path.unlink(missing_ok=True)
  1342. except OSError:
  1343. pass
  1344. async def _broadcast_library_files_changed(self) -> None:
  1345. """Let open File Manager pages refetch: a file arrived outside a request."""
  1346. try:
  1347. from backend.app.core.websocket import ws_manager
  1348. await ws_manager.send_library_files_changed()
  1349. except Exception as e: # noqa: BLE001 -- a broadcast must never fail an upload
  1350. logger.debug("[VP %s] library_files_changed broadcast failed: %s", self.name, e)
  1351. @staticmethod
  1352. def _extract_plate_ids(file_path: Path) -> list[int]:
  1353. """Extract every plate index from a 3MF's slice_info.config.
  1354. A multi-plate "Send All" from BambuStudio / OrcaSlicer uploads a
  1355. single 3MF containing every plate the user selected. Each plate
  1356. has its own ``<plate>`` block with a ``<metadata key="index"
  1357. value="N"/>`` child and its own ``Metadata/plate_N.gcode`` payload
  1358. inside the same zip. Returning the full ordered list lets the VP
  1359. queue path create one queue item per plate (`_add_to_print_queue`
  1360. loops over the result), so "Send All" of a 3-plate file produces
  1361. 3 queue items sharing the same archive — one per plate to print.
  1362. Single-plate "Send" hits the same code path and returns ``[N]``
  1363. for whichever plate the user selected; the loop runs once and the
  1364. existing single-plate behaviour is preserved.
  1365. Returns ``[1]`` when the 3MF is missing ``slice_info.config``,
  1366. unparseable, or contains no plate-index metadata — the original
  1367. single-plate fallback. Production logs at debug so a non-3MF
  1368. upload doesn't spam, but the trail survives for support bundles.
  1369. """
  1370. try:
  1371. import xml.etree.ElementTree as ET
  1372. import zipfile
  1373. with zipfile.ZipFile(file_path, "r") as zf:
  1374. if "Metadata/slice_info.config" in zf.namelist():
  1375. content = zf.read("Metadata/slice_info.config").decode()
  1376. root = ET.fromstring(content) # noqa: S314 # nosec B314
  1377. plate_ids: list[int] = []
  1378. for plate in root.findall(".//plate"):
  1379. for meta in plate.findall("metadata"):
  1380. if meta.get("key") == "index" and meta.get("value"):
  1381. try:
  1382. plate_ids.append(int(meta.get("value")))
  1383. except ValueError:
  1384. continue
  1385. break
  1386. if plate_ids:
  1387. return plate_ids
  1388. except Exception as e:
  1389. logger.debug("[VP] _extract_plate_ids failed for %s: %s", file_path.name, e)
  1390. return [1]
  1391. # -- Service lifecycle --
  1392. def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]:
  1393. """Return (cert_path, key_path, advertise_address) for TLS services.
  1394. Always uses the self-signed cert chain (signed by `bbl_ca`). The user
  1395. imports `bbl_ca.crt` once into the slicer; per-VP certs validate from
  1396. there. Tailscale exposure is handled by the user picking the Tailscale
  1397. IP in the bind_ip dropdown.
  1398. """
  1399. cert_path, key_path = self.generate_certificates()
  1400. advertise = self.remote_interface_ip or self.bind_ip or ""
  1401. return cert_path, key_path, advertise
  1402. async def start_server(self) -> None:
  1403. """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
  1404. logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
  1405. cert_path, key_path, advertise_addr = self._resolve_cert_and_advertise()
  1406. bind_addr = self.bind_ip or "0.0.0.0" # nosec B104
  1407. async def run_with_logging(coro, svc_name):
  1408. try:
  1409. await coro
  1410. except Exception as e:
  1411. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  1412. self._tasks = []
  1413. # FTP server. Each VP gets a non-overlapping passive-mode port slice
  1414. # derived from its DB id so bridge-mode Docker users only have to
  1415. # expose a narrow range (#1646). Default slice is 10 ports per VP;
  1416. # see ftp_server.compute_passive_port_slice for the wrap-around
  1417. # behaviour on installs with very high VP ids.
  1418. passive_port_min, passive_port_max = compute_passive_port_slice(self.id)
  1419. self._ftp = VirtualPrinterFTPServer(
  1420. upload_dir=self.upload_dir,
  1421. access_code=self.access_code,
  1422. cert_path=cert_path,
  1423. key_path=key_path,
  1424. on_file_received=self.on_file_received,
  1425. bind_address=bind_addr,
  1426. vp_name=self.name,
  1427. passive_port_min=passive_port_min,
  1428. passive_port_max=passive_port_max,
  1429. authenticate_user=self.authenticate_user_code,
  1430. )
  1431. self._tasks.append(
  1432. asyncio.create_task(
  1433. run_with_logging(self._ftp.start(), "FTP"),
  1434. name=f"vp_{self.id}_ftp",
  1435. )
  1436. )
  1437. # MQTT server
  1438. self._mqtt = SimpleMQTTServer(
  1439. serial=self.serial,
  1440. access_code=self.access_code,
  1441. cert_path=cert_path,
  1442. key_path=key_path,
  1443. on_print_command=self.on_print_command,
  1444. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1445. bind_address=bind_addr,
  1446. vp_name=self.name,
  1447. authenticate_user=self.authenticate_user_code,
  1448. authorize_control=self.user_may_control_target,
  1449. )
  1450. self._tasks.append(
  1451. asyncio.create_task(
  1452. run_with_logging(self._mqtt.start(), "MQTT"),
  1453. name=f"vp_{self.id}_mqtt",
  1454. )
  1455. )
  1456. # MQTT bridge — fans out the target printer's pushes to slicers connected
  1457. # to this VP and forwards their commands back to the printer. Only meaningful
  1458. # when a target printer is configured AND printer_manager was injected (it
  1459. # always is at runtime; tests may omit it).
  1460. if self.target_printer_id is not None and self._printer_manager is not None:
  1461. self._mqtt_bridge = MQTTBridge(
  1462. vp_id=self.id,
  1463. vp_name=self.name,
  1464. vp_serial=self.serial,
  1465. target_printer_id=self.target_printer_id,
  1466. mqtt_server=self._mqtt,
  1467. printer_manager=self._printer_manager,
  1468. )
  1469. self._mqtt.set_bridge(self._mqtt_bridge)
  1470. await self._mqtt_bridge.start()
  1471. # Camera passthrough. BambuStudio / OrcaSlicer connect the "camera"
  1472. # button to the device IP they bound on (the VP), not the IP in the
  1473. # printer's `ipcam.rtsp_url`. Without a listener the slicer gets
  1474. # connection refused → "LAN connection failed" (RTSP models) or
  1475. # OrcaSlicer error `[2:-10061]` (chamber-image models, #1868).
  1476. #
  1477. # The port depends on the TARGET printer's model:
  1478. # RTSPS (X1/X2/H2/P2S) → 322
  1479. # chamber-image (A1/P1P/P1S) → 6000
  1480. #
  1481. # `get_camera_port()` is the same source of truth used by
  1482. # `routes/camera.py`, so slicer and Bambuddy UI agree.
  1483. target_client = self._printer_manager.get_client(self.target_printer_id)
  1484. target_ip = getattr(target_client, "ip_address", None) if target_client else None
  1485. target_model = getattr(target_client, "model", None) if target_client else None
  1486. if target_ip:
  1487. from backend.app.services.camera import get_camera_port
  1488. camera_port = get_camera_port(target_model)
  1489. # Signs the slicer in with the printer's own code, so the camera
  1490. # also works with a personal access code (#1430).
  1491. self._rtsp_proxy = CameraBridge(
  1492. name=f"[VP {self.name}] Camera-{camera_port}",
  1493. protocol=RTSP if camera_port == 322 else CHAMBER,
  1494. listen_port=camera_port,
  1495. bind_address=bind_addr,
  1496. target_host=target_ip,
  1497. target_port=camera_port,
  1498. cert_path=cert_path,
  1499. key_path=key_path,
  1500. codes=self.camera_codes,
  1501. printer_code=self._target_access_code,
  1502. )
  1503. self._tasks.append(
  1504. asyncio.create_task(
  1505. run_with_logging(self._rtsp_proxy.start(), f"Camera-{camera_port}"),
  1506. name=f"vp_{self.id}_camera",
  1507. )
  1508. )
  1509. # Bind server
  1510. self._bind = BindServer(
  1511. serial=self.serial,
  1512. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1513. name=self.name,
  1514. bind_address=bind_addr,
  1515. cert_path=cert_path,
  1516. key_path=key_path,
  1517. )
  1518. self._tasks.append(
  1519. asyncio.create_task(
  1520. run_with_logging(self._bind.start(), "Bind"),
  1521. name=f"vp_{self.id}_bind",
  1522. )
  1523. )
  1524. # SSDP server — advertise_addr is the remote_interface_ip (Tailscale
  1525. # IP, when chosen from the bind_ip dropdown) or the bind_ip. SSDP
  1526. # Location accepts IPs only; FQDNs go in through bind_ip selection
  1527. # at the printer-IP level and resolve before reaching the SSDP
  1528. # advertisement.
  1529. self._ssdp = VirtualPrinterSSDPServer(
  1530. name=self.name,
  1531. serial=self.serial,
  1532. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1533. advertise_ip=advertise_addr,
  1534. bind_ip=bind_addr,
  1535. )
  1536. self._tasks.append(
  1537. asyncio.create_task(
  1538. run_with_logging(self._ssdp.start(), "SSDP"),
  1539. name=f"vp_{self.id}_ssdp",
  1540. )
  1541. )
  1542. # Wait briefly for every child service to actually finish binding its
  1543. # socket so ``is_running`` doesn't lie. Without this barrier a caller
  1544. # racing the start (e.g. the diagnostic route) would see is_running=True
  1545. # while ports were still in the gap between task creation and the
  1546. # ``asyncio.start_server`` returning. Bounded timeout — if a child
  1547. # hangs we log it and move on; the existing task tracking still
  1548. # catches the failure on the next iteration.
  1549. ready_targets = [
  1550. ("FTP", self._ftp.ready),
  1551. ("MQTT", self._mqtt.ready),
  1552. ("Bind", self._bind.ready),
  1553. ("SSDP", self._ssdp.ready),
  1554. ]
  1555. try:
  1556. await asyncio.wait_for(
  1557. asyncio.gather(*(e.wait() for _, e in ready_targets)),
  1558. timeout=5.0,
  1559. )
  1560. except TimeoutError:
  1561. not_ready = [name for name, e in ready_targets if not e.is_set()]
  1562. logger.warning(
  1563. "[VP %s] Sub-service(s) didn't bind within 5s: %s — continuing anyway",
  1564. self.name,
  1565. ", ".join(not_ready) or "(none)",
  1566. )
  1567. logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
  1568. async def stop_server(self) -> None:
  1569. """Stop server-mode services."""
  1570. if self._finish_release_task is not None and not self._finish_release_task.done():
  1571. self._finish_release_task.cancel()
  1572. self._finish_release_task = None
  1573. if self._mqtt_bridge:
  1574. try:
  1575. await self._mqtt_bridge.stop()
  1576. except Exception:
  1577. logger.exception("[VP %s] MQTT bridge stop failed", self.name)
  1578. if self._mqtt:
  1579. self._mqtt.set_bridge(None)
  1580. self._mqtt_bridge = None
  1581. if self._rtsp_proxy:
  1582. try:
  1583. await self._rtsp_proxy.stop()
  1584. except Exception:
  1585. logger.exception("[VP %s] Camera proxy stop failed", self.name)
  1586. self._rtsp_proxy = None
  1587. if self._ftp:
  1588. await self._ftp.stop()
  1589. self._ftp = None
  1590. if self._mqtt:
  1591. await self._mqtt.stop()
  1592. self._mqtt = None
  1593. if self._bind:
  1594. await self._bind.stop()
  1595. self._bind = None
  1596. if self._ssdp:
  1597. await self._ssdp.stop()
  1598. self._ssdp = None
  1599. await self._cancel_tasks()
  1600. async def start_proxy(self) -> None:
  1601. """Start proxy mode services for this instance."""
  1602. logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
  1603. cert_path, key_path, _ = self._resolve_cert_and_advertise()
  1604. self._proxy = SlicerProxyManager(
  1605. target_host=self.target_printer_ip,
  1606. cert_path=cert_path,
  1607. key_path=key_path,
  1608. on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
  1609. bind_address=self.bind_ip or "0.0.0.0", # nosec B104
  1610. bind_identity={
  1611. "serial": self.target_printer_serial or self.serial,
  1612. "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1613. "name": self.name,
  1614. "version": "01.00.00.00",
  1615. },
  1616. )
  1617. async def run_with_logging(coro, svc_name):
  1618. try:
  1619. await coro
  1620. except Exception as e:
  1621. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  1622. self._tasks = []
  1623. # SSDP for proxy
  1624. proxy_serial = self.target_printer_serial or self.serial
  1625. if self.remote_interface_ip:
  1626. from backend.app.services.network_utils import find_interface_for_ip
  1627. local_iface = find_interface_for_ip(self.target_printer_ip)
  1628. if local_iface:
  1629. self._ssdp_proxy = SSDPProxy(
  1630. local_interface_ip=local_iface["ip"],
  1631. remote_interface_ip=self.remote_interface_ip,
  1632. target_printer_ip=self.target_printer_ip,
  1633. name=self.name,
  1634. )
  1635. self._tasks.append(
  1636. asyncio.create_task(
  1637. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  1638. name=f"vp_{self.id}_ssdp_proxy",
  1639. )
  1640. )
  1641. else:
  1642. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  1643. else:
  1644. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  1645. self._tasks.append(
  1646. asyncio.create_task(
  1647. run_with_logging(self._proxy.start(), "Proxy"),
  1648. name=f"vp_{self.id}_proxy",
  1649. )
  1650. )
  1651. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  1652. """Start single-interface SSDP server as fallback for proxy mode."""
  1653. self._ssdp = VirtualPrinterSSDPServer(
  1654. name=f"{self.name} (Proxy)",
  1655. serial=proxy_serial,
  1656. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1657. advertise_ip=self.bind_ip or "",
  1658. bind_ip=self.bind_ip or "",
  1659. )
  1660. self._tasks.append(
  1661. asyncio.create_task(
  1662. run_with_logging(self._ssdp.start(), "SSDP"),
  1663. name=f"vp_{self.id}_ssdp",
  1664. )
  1665. )
  1666. async def stop_proxy(self) -> None:
  1667. """Stop proxy mode services for this instance."""
  1668. if self._proxy:
  1669. await self._proxy.stop()
  1670. self._proxy = None
  1671. if self._ssdp:
  1672. await self._ssdp.stop()
  1673. self._ssdp = None
  1674. if self._ssdp_proxy:
  1675. await self._ssdp_proxy.stop()
  1676. self._ssdp_proxy = None
  1677. await self._cancel_tasks()
  1678. async def _cancel_tasks(self) -> None:
  1679. """Cancel all running tasks and wait for cleanup."""
  1680. for task in self._tasks:
  1681. task.cancel()
  1682. if self._tasks:
  1683. try:
  1684. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  1685. except TimeoutError:
  1686. pass
  1687. self._tasks = []
  1688. def get_status(self) -> dict:
  1689. """Get status for this instance."""
  1690. status: dict = {
  1691. "running": self.is_running,
  1692. "pending_files": len(self._pending_files),
  1693. }
  1694. if self.is_proxy and self._proxy:
  1695. status["proxy"] = self._proxy.get_status()
  1696. return status
  1697. class VirtualPrinterManager:
  1698. """Multi-instance virtual printer registry and orchestrator.
  1699. Every VP runs its own independent services on a dedicated bind IP.
  1700. """
  1701. def __init__(self):
  1702. self._session_factory: Callable | None = None
  1703. self._printer_manager: PrinterManager | None = None
  1704. self._instances: dict[int, VirtualPrinterInstance] = {}
  1705. # Serialize sync_from_db so concurrent PUT /vp/{id} calls can't
  1706. # race the start/stop sequence and leave duplicate sub-services
  1707. # bound to the same port. The lock is fine-grained enough that
  1708. # a single VP update completes in well under a second; if the
  1709. # user holds the lock with a long-running start they intended
  1710. # to anyway.
  1711. self._sync_lock = asyncio.Lock()
  1712. # Directories
  1713. self._base_dir = app_settings.base_dir / "virtual_printer"
  1714. # Ensure base directories exist
  1715. self._ensure_base_directories()
  1716. def _ensure_base_directories(self) -> None:
  1717. """Create base directories at startup."""
  1718. for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
  1719. try:
  1720. dir_path.mkdir(parents=True, exist_ok=True)
  1721. except PermissionError:
  1722. logger.error(
  1723. f"Cannot create directory {dir_path}: Permission denied. "
  1724. f"For Docker: ensure the data volume is writable by the container user. "
  1725. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  1726. )
  1727. def set_session_factory(self, session_factory: Callable) -> None:
  1728. """Set the database session factory."""
  1729. self._session_factory = session_factory
  1730. def set_printer_manager(self, printer_manager: "PrinterManager") -> None:
  1731. """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
  1732. self._printer_manager = printer_manager
  1733. def get_ca_certificate_info(self) -> dict:
  1734. """Return the shared virtual-printer CA certificate for slicer-trust import.
  1735. The CA is shared by every VP (one import covers all of them). It is
  1736. generated on demand here if no VP has triggered cert generation yet,
  1737. so the "copy/download certificate" UI works even before the first VP
  1738. is enabled.
  1739. """
  1740. certs_dir = self._base_dir / "certs"
  1741. cert_service = CertificateService(cert_dir=certs_dir, shared_ca_dir=certs_dir)
  1742. return cert_service.get_ca_certificate_info()
  1743. @property
  1744. def is_enabled(self) -> bool:
  1745. """Check if any virtual printer is running."""
  1746. return len(self._instances) > 0
  1747. async def sync_from_db(self) -> None:
  1748. """Load all VPs from DB, reconcile running state.
  1749. Serialised by ``self._sync_lock`` — concurrent PUT /vp/{id} routes
  1750. all call into this method; without the lock the start / stop
  1751. sequence races and can leave duplicate sub-services bound to the
  1752. same port or orphan still-running tasks.
  1753. """
  1754. if not self._session_factory:
  1755. logger.warning("Cannot sync virtual printers: no session factory")
  1756. return
  1757. async with self._sync_lock:
  1758. await self._sync_from_db_locked()
  1759. async def _sync_from_db_locked(self) -> None:
  1760. """Inner sync body — caller holds ``self._sync_lock``."""
  1761. from sqlalchemy import select
  1762. from backend.app.models.printer import Printer
  1763. from backend.app.models.virtual_printer import VirtualPrinter
  1764. async with self._session_factory() as db:
  1765. result = await db.execute(
  1766. select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position) # noqa: E712
  1767. )
  1768. enabled_vps = result.scalars().all()
  1769. # Stop instances that are no longer enabled or changed mode
  1770. enabled_ids = {vp.id for vp in enabled_vps}
  1771. for vp_id in list(self._instances.keys()):
  1772. if vp_id not in enabled_ids:
  1773. await self.remove_instance(vp_id)
  1774. # Look up printer IPs for proxy VPs
  1775. proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
  1776. proxy_ips: dict[int, tuple[str, str]] = {}
  1777. if proxy_vps:
  1778. async with self._session_factory() as db:
  1779. for pvp in proxy_vps:
  1780. if pvp.target_printer_id:
  1781. result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
  1782. printer = result.scalar_one_or_none()
  1783. if printer:
  1784. proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
  1785. # Detect config changes on running instances and restart if needed
  1786. for vp in enabled_vps:
  1787. instance = self._instances.get(vp.id)
  1788. if not instance:
  1789. continue
  1790. # Proxy mode: detect target printer IP / serial changes from the
  1791. # DB lookup above. Without this branch a DHCP renewal that gives
  1792. # the target printer a new IP would leave the running proxy
  1793. # forwarding to the stale IP until the user manually toggles the
  1794. # VP. The same shape covers a target-side serial change.
  1795. proxy_target_changed = False
  1796. if vp.mode == "proxy":
  1797. fresh = proxy_ips.get(vp.id)
  1798. if fresh is not None:
  1799. fresh_ip, fresh_serial = fresh
  1800. if (
  1801. getattr(instance, "target_printer_ip", None) != fresh_ip
  1802. or getattr(instance, "target_printer_serial", None) != fresh_serial
  1803. ):
  1804. proxy_target_changed = True
  1805. # Normalize the DB value before comparing — a legacy `immediate`
  1806. # row read before the migration window finishes would otherwise
  1807. # trip the "changed" branch and bounce every VP at boot.
  1808. db_mode = normalize_vp_mode(vp.mode)
  1809. changed = (
  1810. instance.mode != db_mode
  1811. # The name is advertised to the slicer (SSDP, bind) by the
  1812. # running services, so a rename only reaches it on restart.
  1813. or instance.name != vp.name
  1814. or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
  1815. or instance.access_code != (vp.access_code or "")
  1816. or instance.bind_ip != (vp.bind_ip or "")
  1817. or instance.remote_interface_ip != (vp.remote_interface_ip or "")
  1818. or instance.target_printer_id != vp.target_printer_id
  1819. or instance.auto_dispatch != vp.auto_dispatch
  1820. # Queue-mode behaviour toggle — without it the running
  1821. # instance silently keeps the old value until process
  1822. # restart (#1552 follow-up family).
  1823. or instance.queue_force_color_match != vp.queue_force_color_match
  1824. or instance.save_ams_mapping != vp.save_ams_mapping
  1825. or instance.gcode_injection != vp.gcode_injection
  1826. or proxy_target_changed
  1827. )
  1828. if changed:
  1829. logger.info(
  1830. "VP %s config changed (mode: %s→%s), restarting",
  1831. instance.name,
  1832. instance.mode,
  1833. vp.mode,
  1834. )
  1835. await self.remove_instance(vp.id)
  1836. # Start instances for all enabled VPs (skip already running)
  1837. for vp in enabled_vps:
  1838. if vp.id in self._instances:
  1839. continue
  1840. if vp.mode == "proxy":
  1841. ip_info = proxy_ips.get(vp.id)
  1842. if not ip_info:
  1843. logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
  1844. continue
  1845. target_ip, target_serial = ip_info
  1846. instance = VirtualPrinterInstance(
  1847. vp_id=vp.id,
  1848. name=vp.name,
  1849. mode=vp.mode,
  1850. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1851. access_code=vp.access_code or "",
  1852. serial_suffix=vp.serial_suffix,
  1853. target_printer_ip=target_ip,
  1854. target_printer_serial=target_serial,
  1855. # Carried although the proxy does not use them: the change
  1856. # check above compares them with the row, and a missing
  1857. # value restarted every proxy VP on every sync.
  1858. target_printer_id=vp.target_printer_id,
  1859. auto_dispatch=vp.auto_dispatch,
  1860. queue_force_color_match=vp.queue_force_color_match,
  1861. save_ams_mapping=vp.save_ams_mapping,
  1862. gcode_injection=vp.gcode_injection,
  1863. bind_ip=vp.bind_ip or "",
  1864. remote_interface_ip=vp.remote_interface_ip or "",
  1865. base_dir=self._base_dir,
  1866. session_factory=self._session_factory,
  1867. )
  1868. self._instances[vp.id] = instance
  1869. await instance.start_proxy()
  1870. logger.info("Started proxy VP: %s → %s (bind=%s)", instance.name, target_ip, instance.bind_ip)
  1871. else:
  1872. instance = VirtualPrinterInstance(
  1873. vp_id=vp.id,
  1874. name=vp.name,
  1875. mode=vp.mode,
  1876. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1877. access_code=vp.access_code or "",
  1878. serial_suffix=vp.serial_suffix,
  1879. target_printer_id=vp.target_printer_id,
  1880. auto_dispatch=vp.auto_dispatch,
  1881. queue_force_color_match=vp.queue_force_color_match,
  1882. save_ams_mapping=vp.save_ams_mapping,
  1883. gcode_injection=vp.gcode_injection,
  1884. bind_ip=vp.bind_ip or "",
  1885. remote_interface_ip=vp.remote_interface_ip or "",
  1886. base_dir=self._base_dir,
  1887. session_factory=self._session_factory,
  1888. printer_manager=self._printer_manager,
  1889. )
  1890. self._instances[vp.id] = instance
  1891. await instance.start_server()
  1892. logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
  1893. async def remove_instance(self, vp_id: int) -> None:
  1894. """Stop and remove a single VP instance."""
  1895. instance = self._instances.pop(vp_id, None)
  1896. if instance:
  1897. if instance.is_proxy:
  1898. await instance.stop_proxy()
  1899. else:
  1900. await instance.stop_server()
  1901. logger.info("Removed VP instance: %s", instance.name)
  1902. async def stop_all(self) -> None:
  1903. """Shutdown all virtual printer services."""
  1904. logger.info("Stopping all virtual printer services...")
  1905. for vp_id in list(self._instances.keys()):
  1906. await self.remove_instance(vp_id)
  1907. logger.info("All virtual printer services stopped")
  1908. def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
  1909. """Get a running instance by ID."""
  1910. return self._instances.get(vp_id)
  1911. # -- Legacy single-printer compat --
  1912. def get_status(self) -> dict:
  1913. """Get status for first virtual printer (backward compat)."""
  1914. if self._instances:
  1915. first = next(iter(self._instances.values()))
  1916. return {
  1917. "enabled": True,
  1918. "running": first.is_running,
  1919. "mode": first.mode,
  1920. "name": first.name,
  1921. "serial": first.serial,
  1922. "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1923. "model_name": VIRTUAL_PRINTER_MODELS.get(
  1924. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1925. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1926. ),
  1927. "pending_files": first.get_status().get("pending_files", 0),
  1928. **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
  1929. **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
  1930. }
  1931. return {
  1932. "enabled": False,
  1933. "running": False,
  1934. "mode": VP_MODE_LIBRARY,
  1935. "name": "Bambuddy",
  1936. "serial": "",
  1937. "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
  1938. "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
  1939. "pending_files": 0,
  1940. }
  1941. async def configure(
  1942. self,
  1943. enabled: bool,
  1944. access_code: str = "",
  1945. mode: str = VP_MODE_LIBRARY,
  1946. model: str = "",
  1947. target_printer_ip: str = "",
  1948. target_printer_serial: str = "",
  1949. remote_interface_ip: str = "",
  1950. ) -> None:
  1951. """Legacy single-printer configure. Delegates to sync_from_db()."""
  1952. # This method is kept for backward compat with the settings endpoint.
  1953. # The actual work is done by sync_from_db() which reads from the DB.
  1954. await self.sync_from_db()
  1955. # Global instance
  1956. virtual_printer_manager = VirtualPrinterManager()