manager.py 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435
  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 logging
  7. from collections.abc import Callable
  8. from datetime import datetime, timezone
  9. from pathlib import Path
  10. from typing import TYPE_CHECKING
  11. from backend.app.core.config import settings as app_settings
  12. from backend.app.models.virtual_printer import (
  13. VP_MODE_ARCHIVE,
  14. VP_MODE_PROXY,
  15. VP_MODE_QUEUE,
  16. normalize_vp_mode,
  17. )
  18. from backend.app.services.virtual_printer.bind_server import BindServer
  19. from backend.app.services.virtual_printer.certificate import CertificateService
  20. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer, compute_passive_port_slice
  21. from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
  22. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  23. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
  24. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
  25. if TYPE_CHECKING:
  26. from backend.app.services.printer_manager import PrinterManager
  27. logger = logging.getLogger(__name__)
  28. # Mapping of SSDP model codes to display names
  29. # These are the codes that slicers expect during discovery
  30. # Sources:
  31. # - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  32. # - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
  33. VIRTUAL_PRINTER_MODELS = {
  34. # X1 Series
  35. "BL-P001": "X1C", # X1 Carbon
  36. "BL-P002": "X1", # X1
  37. "C13": "X1E", # X1E
  38. # X2 Series
  39. "N6": "X2D", # X2D
  40. # A2 Series (single-FDM + integrated cutter/plotter)
  41. "N9": "A2L", # A2L
  42. # P Series
  43. "C11": "P1P", # P1P
  44. "C12": "P1S", # P1S
  45. "N7": "P2S", # P2S
  46. # A1 Series
  47. "N2S": "A1", # A1
  48. "N1": "A1 Mini", # A1 Mini
  49. # H2 Series
  50. "O1D": "H2D", # H2D
  51. "O1E": "H2D Pro", # H2D Pro
  52. "O2D": "H2D Pro", # H2D Pro
  53. "O1C": "H2C", # H2C
  54. "O1C2": "H2C", # H2C (dual nozzle variant)
  55. "O1S": "H2S", # H2S
  56. }
  57. # Serial number prefixes for each model (based on Bambu Lab serial number format)
  58. # Format: MMM??RYMDDUUUUU (15 chars total)
  59. # MMM = Model prefix (3 chars)
  60. # ?? = Unknown/revision code (2 chars)
  61. # R = Revision letter (1 char)
  62. # Y = Year digit (1 char)
  63. # M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
  64. # DD = Day (2 chars)
  65. # UUUUU = Unit number (5 chars)
  66. MODEL_SERIAL_PREFIXES = {
  67. # X1 Series
  68. "BL-P001": "00M00A", # X1C
  69. "BL-P002": "00M00A", # X1
  70. "C13": "03W00A", # X1E
  71. # X2 Series
  72. "N6": "20P90A", # X2D (first 4 chars "20P9" match real serials)
  73. # A2 Series
  74. "N9": "26A19A", # A2L (first 5 chars "26A19" match real serials)
  75. # P Series
  76. "C11": "01S00A", # P1P
  77. "C12": "01P00A", # P1S
  78. "N7": "22E00A", # P2S
  79. # A1 Series
  80. "N2S": "03900A", # A1
  81. "N1": "03000A", # A1 Mini
  82. # H2 Series
  83. "O1D": "09400A", # H2D
  84. "O1E": "09400A", # H2D Pro (same prefix family as H2D)
  85. "O2D": "09400A", # H2D Pro
  86. "O1C": "09400A", # H2C
  87. "O1C2": "09400A", # H2C (dual nozzle variant)
  88. "O1S": "09400A", # H2S
  89. }
  90. # Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)
  91. DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}
  92. # Default model
  93. DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
  94. # Bound on per-instance ``_slicer_print_options`` cache size. The slicer's
  95. # project_file MQTT command stashes one dict per filename; the
  96. # corresponding ``_add_to_print_queue`` pop only fires when the file
  97. # upload completes. Failed / cancelled / non-3MF uploads orphan their
  98. # stash. The bound triggers FIFO eviction in ``on_print_command`` once
  99. # the dict fills, so a long-running VP can't leak unbounded state.
  100. _SLICER_OPTIONS_CACHE_LIMIT = 128
  101. def _get_serial_for_model(model: str, serial_suffix: str) -> str:
  102. """Get serial number for the given model and suffix."""
  103. prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
  104. return f"{prefix}{serial_suffix}"
  105. class VirtualPrinterInstance:
  106. """Per-printer state and file handling logic.
  107. Each instance represents one virtual printer with its own config,
  108. upload directory, certificates, and file handling mode.
  109. """
  110. def __init__(
  111. self,
  112. *,
  113. vp_id: int,
  114. name: str,
  115. mode: str,
  116. model: str,
  117. access_code: str,
  118. serial_suffix: str,
  119. target_printer_ip: str = "",
  120. target_printer_serial: str = "",
  121. target_printer_id: int | None = None,
  122. auto_dispatch: bool = True,
  123. queue_force_color_match: bool = False,
  124. gcode_injection: bool = False,
  125. bind_ip: str = "",
  126. remote_interface_ip: str = "",
  127. tailscale_disabled: bool = True,
  128. base_dir: Path,
  129. session_factory: Callable | None = None,
  130. printer_manager: "PrinterManager | None" = None,
  131. ):
  132. self.id = vp_id
  133. self.name = name
  134. # Normalize on construction so the rest of the code only compares
  135. # canonical values, even when a legacy DB row hasn't been migrated
  136. # yet (e.g. fresh-from-disk during the boot window before the
  137. # one-shot migration in `core/database.py` has executed).
  138. self.mode = normalize_vp_mode(mode) or VP_MODE_ARCHIVE
  139. self.model = model
  140. self.access_code = access_code
  141. self.serial_suffix = serial_suffix
  142. self.target_printer_ip = target_printer_ip
  143. self.target_printer_serial = target_printer_serial
  144. self.target_printer_id = target_printer_id
  145. self.auto_dispatch = auto_dispatch
  146. self.queue_force_color_match = queue_force_color_match
  147. self.gcode_injection = gcode_injection
  148. self.bind_ip = bind_ip
  149. self.remote_interface_ip = remote_interface_ip
  150. self.tailscale_disabled = tailscale_disabled
  151. self._session_factory = session_factory
  152. self._printer_manager = printer_manager
  153. # Directories
  154. self.upload_dir = base_dir / "uploads" / str(vp_id)
  155. self.cert_dir = base_dir / "certs" / str(vp_id)
  156. shared_ca_dir = base_dir / "certs"
  157. # Ensure directories exist
  158. self.upload_dir.mkdir(parents=True, exist_ok=True)
  159. (self.upload_dir / "cache").mkdir(exist_ok=True)
  160. self.cert_dir.mkdir(parents=True, exist_ok=True)
  161. # Certificate service (shared CA, per-instance printer cert)
  162. self._cert_service = CertificateService(
  163. cert_dir=self.cert_dir,
  164. serial=self.serial,
  165. shared_ca_dir=shared_ca_dir,
  166. )
  167. # Pending files for MQTT correlation
  168. self._pending_files: dict[str, Path] = {}
  169. # Slicer-side print options captured from the MQTT `project_file`
  170. # command, keyed by filename. Used by `_add_to_print_queue` so the
  171. # queue item inherits the user's slicer-chosen timelapse / bed_leveling
  172. # / flow_cali / vibration_cali / layer_inspect / use_ams toggles rather
  173. # than falling back to the global `default_*` settings (#1403). FTP
  174. # completes a few hundred ms before the slicer's MQTT `project_file`
  175. # arrives, so the queue-add path waits briefly on the event below
  176. # before reading the dict. Events are popped along with the options
  177. # so the dict stays bounded.
  178. self._slicer_print_options: dict[str, dict] = {}
  179. self._slicer_print_options_events: dict[str, asyncio.Event] = {}
  180. # Per-instance services
  181. self._proxy: SlicerProxyManager | None = None
  182. self._ftp: VirtualPrinterFTPServer | None = None
  183. self._mqtt: SimpleMQTTServer | None = None
  184. self._mqtt_bridge: MQTTBridge | None = None
  185. self._rtsp_proxy: TCPProxy | None = None
  186. self._bind: BindServer | None = None
  187. self._ssdp: VirtualPrinterSSDPServer | None = None
  188. self._ssdp_proxy: SSDPProxy | None = None
  189. self._tasks: list[asyncio.Task] = []
  190. # Pending timer that re-fires gcode_state=FINISH after a project_file
  191. # ack. See ``_schedule_finish_release`` for the #1658 rationale.
  192. self._finish_release_task: asyncio.Task | None = None
  193. @property
  194. def serial(self) -> str:
  195. """Full serial number for this virtual printer."""
  196. return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)
  197. @property
  198. def cert_path(self) -> Path:
  199. return self._cert_service.cert_path
  200. @property
  201. def key_path(self) -> Path:
  202. return self._cert_service.key_path
  203. @property
  204. def is_proxy(self) -> bool:
  205. return self.mode == "proxy"
  206. @property
  207. def is_running(self) -> bool:
  208. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  209. def generate_certificates(self) -> tuple[Path, Path]:
  210. """Generate certificates for this instance."""
  211. self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)
  212. additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None
  213. if self.bind_ip:
  214. additional_ips = additional_ips or []
  215. additional_ips.append(self.bind_ip)
  216. self._cert_service.delete_printer_certificate()
  217. return self._cert_service.generate_certificates(additional_ips=additional_ips)
  218. # -- File handling callbacks --
  219. async def on_file_received(self, file_path: Path, source_ip: str) -> None:
  220. """Handle file upload completion from FTP."""
  221. logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
  222. self._pending_files[file_path.name] = file_path
  223. # Accept both canonical (`archive`/`queue`) and legacy
  224. # (`immediate`/`print_queue`) wire values so a stale row that hasn't
  225. # been migrated yet still dispatches correctly. Migration in
  226. # `core/database.py` rewrites existing rows once at boot.
  227. mode = normalize_vp_mode(self.mode)
  228. if mode == VP_MODE_ARCHIVE:
  229. await self._archive_file(file_path, source_ip)
  230. elif mode == VP_MODE_QUEUE:
  231. await self._add_to_print_queue(file_path, source_ip)
  232. else:
  233. await self._queue_file(file_path, source_ip)
  234. # Signal job completion to the slicer. Send-flow slicers don't watch the
  235. # post-upload state and would be happy with anything; the Print flow
  236. # (intended for proxy-mode VPs, but users sometimes click it against
  237. # queue/immediate/review modes too — #1280) watches the gcode_state
  238. # cycle and only releases its in-flight-job lock when it sees FINISH.
  239. # Going PREPARE → IDLE wedges the slicer's UI at "Downloading...(0%)"
  240. # and blocks the next dispatch with "busy with another print job".
  241. # PREPARE → FINISH satisfies both flows. prepare_percent=100 also
  242. # unfreezes the slicer's "Downloading X%" progress bar which it ticks
  243. # against the same field during the upload window.
  244. if self._mqtt and file_path.suffix.lower() == ".3mf":
  245. self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
  246. # FINISH is the terminal state for the upload cycle per #1280
  247. # (commit 0d6171dc). The Print-flow slicer's in-flight-job lock
  248. # releases on FINISH; resetting to IDLE 2 s later would re-confuse
  249. # the slicer that just unwedged. Earlier audit suggesting the
  250. # IDLE reset was wrong — staying at FINISH is the designed
  251. # behaviour. The next upload's PREPARE→FINISH cycle starts fresh.
  252. async def on_print_command(self, filename: str, data: dict) -> None:
  253. """Handle print command from MQTT.
  254. Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
  255. `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
  256. VP-queue path can inherit them when adding the item to the queue,
  257. rather than falling back to the global default settings (#1403).
  258. Only queue mode consumes the capture; archive / review / proxy
  259. modes ignore the print command, so we skip the stash there to keep
  260. the dict from accumulating one entry per print over the VP's
  261. uptime.
  262. Also schedules the #1658 follow-up that re-fires gcode_state=FINISH a
  263. moment after the synthetic project_file ack — for every non-proxy
  264. mode — so the slicer's "Downloading" UI releases on the slicer's
  265. FTP-first-then-MQTT send order.
  266. """
  267. logger.info("[VP %s] Print command for: %s", self.name, filename)
  268. mode = normalize_vp_mode(self.mode)
  269. if mode != VP_MODE_PROXY and filename and self._mqtt is not None:
  270. self._schedule_finish_release(filename)
  271. if mode != VP_MODE_QUEUE:
  272. return
  273. # Drop the oldest stash if the cache is growing — happens when the
  274. # slicer sends project_file for a filename whose FTP upload was
  275. # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
  276. # never fires. With no bound, a long-running VP accumulates one
  277. # dict per such mismatch.
  278. if len(self._slicer_print_options) >= _SLICER_OPTIONS_CACHE_LIMIT:
  279. try:
  280. stale_key = next(iter(self._slicer_print_options))
  281. self._slicer_print_options.pop(stale_key, None)
  282. self._slicer_print_options_events.pop(stale_key, None)
  283. logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
  284. except StopIteration:
  285. pass
  286. self._slicer_print_options[filename] = dict(data)
  287. event = self._slicer_print_options_events.get(filename)
  288. if event:
  289. event.set()
  290. def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
  291. """Re-set gcode_state=FINISH on the VP after the project_file ack.
  292. #1280 set FINISH after the FTP upload completes — that was correct
  293. for the slicer flow at the time (MQTT project_file → FTP → done).
  294. Bambu Studio 2.7.x flipped the order to FTP → FTP → MQTT project_file,
  295. which means ``_send_print_response`` runs *after* the FINISH set in
  296. ``on_file_received`` and overwrites the state back to PREPARE. The
  297. slicer's 1 Hz status stream then carries PREPARE forever and the
  298. send modal sits at "Downloading" until the VP is restarted (#1658).
  299. Re-firing FINISH after a short delay closes the gap: the slicer sees
  300. the synthetic PREPARE in the project_file ack (and likely one PREPARE
  301. push on the 1 Hz cycle), then the next push carries FINISH and the
  302. modal releases. Proxy mode is exempt — there the real printer drives
  303. the state through the bridge and a synthetic FINISH would clobber a
  304. real PREPARE/RUNNING transition coming back from the printer.
  305. Cancels any in-flight timer before scheduling a new one so a slicer
  306. that fires project_file twice in quick succession only ends in one
  307. FINISH.
  308. """
  309. if self._mqtt is None:
  310. return
  311. if self._finish_release_task is not None and not self._finish_release_task.done():
  312. self._finish_release_task.cancel()
  313. self._finish_release_task = asyncio.create_task(
  314. self._delayed_finish_release(filename, delay),
  315. name=f"vp-{self.id}-finish-release",
  316. )
  317. async def _delayed_finish_release(self, filename: str, delay: float) -> None:
  318. """Sleep, then set gcode_state=FINISH. Used by ``_schedule_finish_release``."""
  319. try:
  320. await asyncio.sleep(delay)
  321. except asyncio.CancelledError:
  322. return
  323. if self._mqtt is None:
  324. return
  325. self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
  326. logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
  327. async def _archive_file(self, file_path: Path, source_ip: str) -> None:
  328. """Archive file immediately."""
  329. if not self._session_factory:
  330. logger.error("Cannot archive: no database session factory configured")
  331. return
  332. if file_path.suffix.lower() != ".3mf":
  333. logger.debug("Skipping non-3MF file: %s", file_path.name)
  334. self._pending_files.pop(file_path.name, None)
  335. try:
  336. file_path.unlink()
  337. except OSError:
  338. pass
  339. return
  340. archived = False
  341. try:
  342. from backend.app.api.routes.settings import get_setting
  343. from backend.app.services.archive import ArchiveService
  344. async with self._session_factory() as db:
  345. name_source = await get_setting(db, "virtual_printer_archive_name_source")
  346. prefer_filename = name_source == "filename"
  347. service = ArchiveService(db)
  348. archive = await service.archive_print(
  349. printer_id=None,
  350. source_file=file_path,
  351. print_data={
  352. "status": "archived",
  353. "source": "virtual_printer",
  354. "source_ip": source_ip,
  355. },
  356. prefer_filename_for_name=prefer_filename,
  357. )
  358. if archive:
  359. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  360. await self._broadcast_archive_created(archive)
  361. archived = True
  362. else:
  363. logger.error("Failed to archive file: %s", file_path.name)
  364. except Exception as e:
  365. logger.error("Error archiving file: %s", e)
  366. finally:
  367. # Always release the in-flight marker and delete the temp file —
  368. # previously the failure paths only logged and the next upload of
  369. # the same name was silently rejected with "already uploading",
  370. # the upload_dir filled up indefinitely, and the slicer received
  371. # a clean 226 even though no archive existed (#audit-R2-1).
  372. self._pending_files.pop(file_path.name, None)
  373. if archived:
  374. try:
  375. file_path.unlink()
  376. except OSError:
  377. pass
  378. else:
  379. # Drop the failed temp file so it doesn't accumulate.
  380. try:
  381. file_path.unlink(missing_ok=True)
  382. except OSError:
  383. pass
  384. async def _queue_file(self, file_path: Path, source_ip: str) -> None:
  385. """Queue file for user review."""
  386. if not self._session_factory:
  387. logger.error("Cannot queue: no database session factory configured")
  388. return
  389. if file_path.suffix.lower() != ".3mf":
  390. self._pending_files.pop(file_path.name, None)
  391. try:
  392. file_path.unlink()
  393. except OSError:
  394. pass
  395. return
  396. # Peek at the 3MF for the embedded title BEFORE we hand it off to the
  397. # DB. Storing it now means the /pending-uploads/ list doesn't have to
  398. # reopen every 3MF on every render to keep the review card and the
  399. # eventual archive name in sync (#1152 follow-up). Failure to parse is
  400. # not fatal — the response model falls back to the filename stem.
  401. metadata_print_name: str | None = None
  402. try:
  403. from backend.app.services.archive import ThreeMFParser
  404. parsed = ThreeMFParser(file_path).parse()
  405. raw_name = parsed.get("print_name")
  406. if isinstance(raw_name, str) and raw_name.strip():
  407. metadata_print_name = raw_name.strip()[:255]
  408. except Exception as e:
  409. logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
  410. try:
  411. from backend.app.models.pending_upload import PendingUpload
  412. async with self._session_factory() as db:
  413. pending = PendingUpload(
  414. filename=file_path.name,
  415. file_path=str(file_path),
  416. file_size=file_path.stat().st_size,
  417. source_ip=source_ip,
  418. status="pending",
  419. uploaded_at=datetime.now(timezone.utc),
  420. metadata_print_name=metadata_print_name,
  421. )
  422. db.add(pending)
  423. await db.commit()
  424. logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name)
  425. except Exception as e:
  426. logger.error("Error queueing file: %s", e)
  427. # Queue insert failed — drop the temp file so it doesn't
  428. # accumulate. The file is unreachable without the DB row.
  429. try:
  430. file_path.unlink(missing_ok=True)
  431. except OSError:
  432. pass
  433. finally:
  434. # Always release the in-flight marker so concurrent uploads
  435. # with the same filename aren't spuriously rejected after
  436. # a queue failure.
  437. self._pending_files.pop(file_path.name, None)
  438. async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
  439. """Archive file and add to print queue, assigned to target printer or model."""
  440. if not self._session_factory:
  441. logger.error("Cannot add to print queue: no database session factory configured")
  442. return
  443. if file_path.suffix.lower() != ".3mf":
  444. self._pending_files.pop(file_path.name, None)
  445. try:
  446. file_path.unlink()
  447. except OSError:
  448. pass
  449. return
  450. # Wait briefly for the slicer's MQTT `project_file` command so the
  451. # queue item can inherit the slicer-side print options the user
  452. # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
  453. # first and the MQTT command immediately after, so the typical lag
  454. # is a few hundred ms; 2 s is conservative without making every
  455. # VP-queue add visibly slow. Falls back to the global default_*
  456. # settings if MQTT doesn't arrive in time (legacy behaviour for
  457. # users on a slicer that doesn't send a print command). #1403.
  458. # The wait is skipped when there's no MQTT server attached — covers
  459. # unit tests that invoke `_add_to_print_queue` directly without
  460. # going through `on_print_command`, so they don't pay the 2 s tax.
  461. slicer_opts = self._slicer_print_options.pop(file_path.name, None)
  462. if slicer_opts is None and self._mqtt is not None:
  463. event = asyncio.Event()
  464. self._slicer_print_options_events[file_path.name] = event
  465. try:
  466. await asyncio.wait_for(event.wait(), timeout=2.0)
  467. slicer_opts = self._slicer_print_options.pop(file_path.name, None)
  468. except asyncio.TimeoutError:
  469. slicer_opts = None
  470. finally:
  471. self._slicer_print_options_events.pop(file_path.name, None)
  472. try:
  473. import json
  474. from backend.app.api.routes.settings import get_setting
  475. from backend.app.models.print_queue import PrintQueueItem
  476. from backend.app.services.archive import ArchiveService
  477. from backend.app.services.filament_requirements import extract_filament_requirements
  478. async with self._session_factory() as db:
  479. name_source = await get_setting(db, "virtual_printer_archive_name_source")
  480. prefer_filename = name_source == "filename"
  481. # Read workflow defaults from settings. Without this the
  482. # PrintQueueItem below would fall back to the column-level
  483. # defaults and ignore the user's workflow preferences (#1235).
  484. # Fallbacks match AppSettings defaults in schemas/settings.py.
  485. # The slicer-side options captured above (if any) take
  486. # precedence per-field over these defaults.
  487. def _bool_setting(value: str | None, default: bool) -> bool:
  488. return value.lower() == "true" if value is not None else default
  489. def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
  490. """Slicer's MQTT value if present, else the settings default.
  491. Slicer payloads carry both bool and int (0/1) shapes
  492. depending on firmware family — coerce via bool() so
  493. `0`/`False` and `1`/`True` both work.
  494. """
  495. if slicer_opts is not None and field_mqtt in slicer_opts:
  496. return bool(slicer_opts[field_mqtt])
  497. return settings_default
  498. # Note the MQTT field names differ from Bambuddy's column
  499. # names: MQTT uses `bed_leveling` (single L) while the
  500. # column / settings key use `bed_levelling` (double L).
  501. bed_levelling = _slicer_or(
  502. "bed_leveling", _bool_setting(await get_setting(db, "default_bed_levelling"), True)
  503. )
  504. flow_cali = _slicer_or("flow_cali", _bool_setting(await get_setting(db, "default_flow_cali"), False))
  505. vibration_cali = _slicer_or(
  506. "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
  507. )
  508. layer_inspect = _slicer_or(
  509. "layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
  510. )
  511. timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
  512. # H2C dual-nozzle-rack slicer-pick preservation (#1780).
  513. # BambuStudio's project_file MQTT command for rack-swap models
  514. # (O1C2 today) carries:
  515. # `nozzle_mapping` — per-filament array of physical nozzle
  516. # position IDs (`list[int]`).
  517. # `nozzles_info` — per-extruder rack metadata
  518. # (`list[dict]`, fields: id / type / flowSize / diameter).
  519. # Forward both verbatim onto the queue item so the dispatcher
  520. # can replay them in its own project_file command. Without
  521. # this the H2C firmware falls back to "last matching nozzle"
  522. # auto-pick and ignores the user's Bambu Studio choice. Every
  523. # other model has these absent from slicer_opts, so the
  524. # capture is a transparent no-op there.
  525. nozzle_mapping_json: str | None = None
  526. nozzles_info_json: str | None = None
  527. if slicer_opts is not None:
  528. for src_key in ("nozzle_mapping", "nozzles_info"):
  529. raw = slicer_opts.get(src_key)
  530. if raw is None:
  531. continue
  532. # BambuStudio's NetworkAgent should embed these as
  533. # parsed JSON in the project_file body (matching the
  534. # ams_mapping / ams_mapping2 shape Bambuddy already
  535. # consumes as list[int] / list[dict]). Accept a
  536. # JSON-encoded string defensively in case any path
  537. # arrives stringified.
  538. if isinstance(raw, str):
  539. try:
  540. raw = json.loads(raw)
  541. except json.JSONDecodeError:
  542. logger.warning(
  543. "[VP %s] Slicer %s is unparseable JSON, dropping: %r",
  544. self.name,
  545. src_key,
  546. raw,
  547. )
  548. continue
  549. encoded = json.dumps(raw)
  550. if src_key == "nozzle_mapping":
  551. nozzle_mapping_json = encoded
  552. else:
  553. nozzles_info_json = encoded
  554. service = ArchiveService(db)
  555. archive = await service.archive_print(
  556. printer_id=None,
  557. source_file=file_path,
  558. print_data={
  559. "status": "archived",
  560. "source": "virtual_printer",
  561. "source_ip": source_ip,
  562. },
  563. prefer_filename_for_name=prefer_filename,
  564. )
  565. if archive:
  566. logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
  567. # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
  568. target_model = None
  569. if not self.target_printer_id and self.model:
  570. target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
  571. # #1733: multi-plate "Send All" uploads ship every plate in
  572. # one 3MF — `slice_info.config` lists each `<plate>` with
  573. # its own index. Enqueue one PrintQueueItem per plate so
  574. # the scheduler runs each separately. Single-plate "Send"
  575. # comes through as `[N]` (one plate index) so the loop
  576. # below runs once and the existing behaviour is preserved.
  577. plate_ids = self._extract_plate_ids(file_path)
  578. # Pick a base position the same way the manual /print-queue/
  579. # POST does, then hand consecutive positions to each plate
  580. # so a Send All keeps plate-order execution inside the
  581. # queue (#1733). Previously hardcoded to 1, which created
  582. # duplicate position=1 rows on every VP upload and made
  583. # queue execution order non-deterministic for any non-
  584. # empty queue.
  585. from sqlalchemy import func, select as _sql_select
  586. queue_scope = _sql_select(func.max(PrintQueueItem.position)).where(
  587. PrintQueueItem.status == "pending"
  588. )
  589. if self.target_printer_id is not None:
  590. queue_scope = queue_scope.where(PrintQueueItem.printer_id == self.target_printer_id)
  591. else:
  592. queue_scope = queue_scope.where(PrintQueueItem.printer_id.is_(None))
  593. try:
  594. max_pos_raw = (await db.execute(queue_scope)).scalar()
  595. max_pos = int(max_pos_raw) if max_pos_raw is not None else 0
  596. except (TypeError, ValueError):
  597. max_pos = 0
  598. # Parse per-plate filament requirements (#1188). Each plate
  599. # has its own filament set in `slice_info.config`, so the
  600. # `required_filament_types` / `filament_overrides` columns
  601. # on each queue item reflect THAT plate, not the file's
  602. # first plate. Scoping was already plate-aware via #1697 —
  603. # the `extract_filament_requirements(path, plate_id)` filter
  604. # returns just the plate's filaments. required_filament_types
  605. # is populated unconditionally — it's cheap, lets the
  606. # scheduler reject obvious mis-matches even without
  607. # force_color_match. filament_overrides only carries
  608. # force_color_match=True when the per-VP setting is on, so
  609. # upgraders keep the old behaviour by default.
  610. queue_item_ids: list[int] = []
  611. for offset, plate_id in enumerate(plate_ids, start=1):
  612. required_filament_types_json: str | None = None
  613. filament_overrides_json: str | None = None
  614. requirements = extract_filament_requirements(file_path, plate_id)
  615. if requirements:
  616. types = sorted({r["type"] for r in requirements if r.get("type")})
  617. if types:
  618. required_filament_types_json = json.dumps(types)
  619. if self.queue_force_color_match:
  620. overrides = [
  621. {
  622. "slot_id": r["slot_id"],
  623. "type": r.get("type", ""),
  624. "color": r.get("color", ""),
  625. "force_color_match": True,
  626. }
  627. for r in requirements
  628. if r.get("type") and r.get("color")
  629. ]
  630. if overrides:
  631. filament_overrides_json = json.dumps(overrides)
  632. queue_item = PrintQueueItem(
  633. printer_id=self.target_printer_id,
  634. target_model=target_model,
  635. archive_id=archive.id,
  636. plate_id=plate_id,
  637. position=max_pos + offset,
  638. status="pending",
  639. manual_start=not self.auto_dispatch,
  640. required_filament_types=required_filament_types_json,
  641. filament_overrides=filament_overrides_json,
  642. bed_levelling=bed_levelling,
  643. flow_cali=flow_cali,
  644. vibration_cali=vibration_cali,
  645. layer_inspect=layer_inspect,
  646. timelapse=timelapse,
  647. # Per-VP opt-in for auto-print G-code injection (#1516).
  648. # Default off; when on, the scheduler still no-ops unless
  649. # gcode_snippets are configured for the target model, so it's
  650. # effectively "inject when enabled AND snippets exist".
  651. gcode_injection=self.gcode_injection,
  652. # H2C rack-swap slicer pick (#1780). Captured above;
  653. # stamped on every plate so a multi-plate Send All keeps
  654. # the same nozzle pick across plates rather than only the
  655. # first one (mirrors the #1697 / #1188 per-plate loop fix).
  656. nozzle_mapping=nozzle_mapping_json,
  657. nozzles_info=nozzles_info_json,
  658. )
  659. db.add(queue_item)
  660. await db.flush() # populate queue_item.id before logging
  661. queue_item_ids.append(queue_item.id)
  662. await db.commit()
  663. if len(queue_item_ids) == 1:
  664. logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
  665. else:
  666. logger.info(
  667. "[VP %s] Added %d queue items for multi-plate upload (plates %s): %s",
  668. self.name,
  669. len(queue_item_ids),
  670. plate_ids,
  671. queue_item_ids,
  672. )
  673. await self._broadcast_archive_created(archive)
  674. else:
  675. logger.error("Failed to archive file: %s", file_path.name)
  676. except Exception as e:
  677. logger.error("Error adding to print queue: %s", e)
  678. finally:
  679. # Always release the marker and clean the temp file. Without this
  680. # the same-name STOR guard would block the next upload and the
  681. # upload_dir would accumulate failed temp files forever
  682. # (#audit-R2-1).
  683. self._pending_files.pop(file_path.name, None)
  684. try:
  685. file_path.unlink(missing_ok=True)
  686. except OSError:
  687. pass
  688. async def _broadcast_archive_created(self, archive) -> None:
  689. """Notify connected clients that a new archive exists.
  690. Real-printer prints get this from main.py's MQTT print_start handler;
  691. VP-uploaded prints need their own broadcast or the Archives page stays
  692. stale until the user switches tabs (#1282).
  693. """
  694. try:
  695. from backend.app.core.websocket import ws_manager
  696. await ws_manager.send_archive_created(
  697. {
  698. "id": archive.id,
  699. "printer_id": archive.printer_id,
  700. "filename": archive.filename,
  701. "print_name": archive.print_name,
  702. "status": archive.status,
  703. }
  704. )
  705. except Exception as e:
  706. logger.debug("[VP %s] archive_created broadcast failed: %s", self.name, e)
  707. @staticmethod
  708. def _extract_plate_ids(file_path: Path) -> list[int]:
  709. """Extract every plate index from a 3MF's slice_info.config.
  710. A multi-plate "Send All" from BambuStudio / OrcaSlicer uploads a
  711. single 3MF containing every plate the user selected. Each plate
  712. has its own ``<plate>`` block with a ``<metadata key="index"
  713. value="N"/>`` child and its own ``Metadata/plate_N.gcode`` payload
  714. inside the same zip. Returning the full ordered list lets the VP
  715. queue path create one queue item per plate (`_add_to_print_queue`
  716. loops over the result), so "Send All" of a 3-plate file produces
  717. 3 queue items sharing the same archive — one per plate to print.
  718. Single-plate "Send" hits the same code path and returns ``[N]``
  719. for whichever plate the user selected; the loop runs once and the
  720. existing single-plate behaviour is preserved.
  721. Returns ``[1]`` when the 3MF is missing ``slice_info.config``,
  722. unparseable, or contains no plate-index metadata — the original
  723. single-plate fallback. Production logs at debug so a non-3MF
  724. upload doesn't spam, but the trail survives for support bundles.
  725. """
  726. try:
  727. import xml.etree.ElementTree as ET
  728. import zipfile
  729. with zipfile.ZipFile(file_path, "r") as zf:
  730. if "Metadata/slice_info.config" in zf.namelist():
  731. content = zf.read("Metadata/slice_info.config").decode()
  732. root = ET.fromstring(content) # noqa: S314 # nosec B314
  733. plate_ids: list[int] = []
  734. for plate in root.findall(".//plate"):
  735. for meta in plate.findall("metadata"):
  736. if meta.get("key") == "index" and meta.get("value"):
  737. try:
  738. plate_ids.append(int(meta.get("value")))
  739. except ValueError:
  740. continue
  741. break
  742. if plate_ids:
  743. return plate_ids
  744. except Exception as e:
  745. logger.debug("[VP] _extract_plate_ids failed for %s: %s", file_path.name, e)
  746. return [1]
  747. # -- Service lifecycle --
  748. def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]:
  749. """Return (cert_path, key_path, advertise_address) for TLS services.
  750. Always uses the self-signed cert chain (signed by `bbl_ca`). The user
  751. imports `bbl_ca.crt` once into the slicer; per-VP certs validate from
  752. there. Tailscale exposure is handled by the user picking the Tailscale
  753. IP in the bind_ip dropdown.
  754. """
  755. cert_path, key_path = self.generate_certificates()
  756. advertise = self.remote_interface_ip or self.bind_ip or ""
  757. return cert_path, key_path, advertise
  758. async def start_server(self) -> None:
  759. """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
  760. logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
  761. cert_path, key_path, advertise_addr = self._resolve_cert_and_advertise()
  762. bind_addr = self.bind_ip or "0.0.0.0" # nosec B104
  763. async def run_with_logging(coro, svc_name):
  764. try:
  765. await coro
  766. except Exception as e:
  767. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  768. self._tasks = []
  769. # FTP server. Each VP gets a non-overlapping passive-mode port slice
  770. # derived from its DB id so bridge-mode Docker users only have to
  771. # expose a narrow range (#1646). Default slice is 10 ports per VP;
  772. # see ftp_server.compute_passive_port_slice for the wrap-around
  773. # behaviour on installs with very high VP ids.
  774. passive_port_min, passive_port_max = compute_passive_port_slice(self.id)
  775. self._ftp = VirtualPrinterFTPServer(
  776. upload_dir=self.upload_dir,
  777. access_code=self.access_code,
  778. cert_path=cert_path,
  779. key_path=key_path,
  780. on_file_received=self.on_file_received,
  781. bind_address=bind_addr,
  782. vp_name=self.name,
  783. passive_port_min=passive_port_min,
  784. passive_port_max=passive_port_max,
  785. )
  786. self._tasks.append(
  787. asyncio.create_task(
  788. run_with_logging(self._ftp.start(), "FTP"),
  789. name=f"vp_{self.id}_ftp",
  790. )
  791. )
  792. # MQTT server
  793. self._mqtt = SimpleMQTTServer(
  794. serial=self.serial,
  795. access_code=self.access_code,
  796. cert_path=cert_path,
  797. key_path=key_path,
  798. on_print_command=self.on_print_command,
  799. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  800. bind_address=bind_addr,
  801. vp_name=self.name,
  802. )
  803. self._tasks.append(
  804. asyncio.create_task(
  805. run_with_logging(self._mqtt.start(), "MQTT"),
  806. name=f"vp_{self.id}_mqtt",
  807. )
  808. )
  809. # MQTT bridge — fans out the target printer's pushes to slicers connected
  810. # to this VP and forwards their commands back to the printer. Only meaningful
  811. # when a target printer is configured AND printer_manager was injected (it
  812. # always is at runtime; tests may omit it).
  813. if self.target_printer_id is not None and self._printer_manager is not None:
  814. self._mqtt_bridge = MQTTBridge(
  815. vp_id=self.id,
  816. vp_name=self.name,
  817. vp_serial=self.serial,
  818. target_printer_id=self.target_printer_id,
  819. mqtt_server=self._mqtt,
  820. printer_manager=self._printer_manager,
  821. )
  822. self._mqtt.set_bridge(self._mqtt_bridge)
  823. await self._mqtt_bridge.start()
  824. # RTSPS camera passthrough on port 322. BambuStudio's camera button
  825. # connects to the device IP it bound on (the VP), not the IP in
  826. # `ipcam.rtsp_url`. Without a listener on <bind_ip>:322 the slicer
  827. # gets connection refused → "LAN connection failed". Same raw TCP
  828. # pass-through used by SlicerProxyManager in proxy mode.
  829. target_client = self._printer_manager.get_client(self.target_printer_id)
  830. target_ip = getattr(target_client, "ip_address", None) if target_client else None
  831. if target_ip:
  832. self._rtsp_proxy = TCPProxy(
  833. name="RTSP",
  834. listen_port=322,
  835. target_host=target_ip,
  836. target_port=322,
  837. bind_address=bind_addr,
  838. )
  839. self._tasks.append(
  840. asyncio.create_task(
  841. run_with_logging(self._rtsp_proxy.start(), "RTSP"),
  842. name=f"vp_{self.id}_rtsp",
  843. )
  844. )
  845. # Bind server
  846. self._bind = BindServer(
  847. serial=self.serial,
  848. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  849. name=self.name,
  850. bind_address=bind_addr,
  851. cert_path=cert_path,
  852. key_path=key_path,
  853. )
  854. self._tasks.append(
  855. asyncio.create_task(
  856. run_with_logging(self._bind.start(), "Bind"),
  857. name=f"vp_{self.id}_bind",
  858. )
  859. )
  860. # SSDP server — advertise_addr is the remote_interface_ip (Tailscale
  861. # IP, when chosen from the bind_ip dropdown) or the bind_ip. SSDP
  862. # Location accepts IPs only; FQDNs go in through bind_ip selection
  863. # at the printer-IP level and resolve before reaching the SSDP
  864. # advertisement.
  865. self._ssdp = VirtualPrinterSSDPServer(
  866. name=self.name,
  867. serial=self.serial,
  868. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  869. advertise_ip=advertise_addr,
  870. bind_ip=bind_addr,
  871. )
  872. self._tasks.append(
  873. asyncio.create_task(
  874. run_with_logging(self._ssdp.start(), "SSDP"),
  875. name=f"vp_{self.id}_ssdp",
  876. )
  877. )
  878. # Wait briefly for every child service to actually finish binding its
  879. # socket so ``is_running`` doesn't lie. Without this barrier a caller
  880. # racing the start (e.g. the diagnostic route) would see is_running=True
  881. # while ports were still in the gap between task creation and the
  882. # ``asyncio.start_server`` returning. Bounded timeout — if a child
  883. # hangs we log it and move on; the existing task tracking still
  884. # catches the failure on the next iteration.
  885. ready_targets = [
  886. ("FTP", self._ftp.ready),
  887. ("MQTT", self._mqtt.ready),
  888. ("Bind", self._bind.ready),
  889. ("SSDP", self._ssdp.ready),
  890. ]
  891. try:
  892. await asyncio.wait_for(
  893. asyncio.gather(*(e.wait() for _, e in ready_targets)),
  894. timeout=5.0,
  895. )
  896. except TimeoutError:
  897. not_ready = [name for name, e in ready_targets if not e.is_set()]
  898. logger.warning(
  899. "[VP %s] Sub-service(s) didn't bind within 5s: %s — continuing anyway",
  900. self.name,
  901. ", ".join(not_ready) or "(none)",
  902. )
  903. logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
  904. async def stop_server(self) -> None:
  905. """Stop server-mode services."""
  906. if self._finish_release_task is not None and not self._finish_release_task.done():
  907. self._finish_release_task.cancel()
  908. self._finish_release_task = None
  909. if self._mqtt_bridge:
  910. try:
  911. await self._mqtt_bridge.stop()
  912. except Exception:
  913. logger.exception("[VP %s] MQTT bridge stop failed", self.name)
  914. if self._mqtt:
  915. self._mqtt.set_bridge(None)
  916. self._mqtt_bridge = None
  917. if self._rtsp_proxy:
  918. try:
  919. await self._rtsp_proxy.stop()
  920. except Exception:
  921. logger.exception("[VP %s] RTSP proxy stop failed", self.name)
  922. self._rtsp_proxy = None
  923. if self._ftp:
  924. await self._ftp.stop()
  925. self._ftp = None
  926. if self._mqtt:
  927. await self._mqtt.stop()
  928. self._mqtt = None
  929. if self._bind:
  930. await self._bind.stop()
  931. self._bind = None
  932. if self._ssdp:
  933. await self._ssdp.stop()
  934. self._ssdp = None
  935. await self._cancel_tasks()
  936. async def start_proxy(self) -> None:
  937. """Start proxy mode services for this instance."""
  938. logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
  939. cert_path, key_path, _ = self._resolve_cert_and_advertise()
  940. self._proxy = SlicerProxyManager(
  941. target_host=self.target_printer_ip,
  942. cert_path=cert_path,
  943. key_path=key_path,
  944. on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
  945. bind_address=self.bind_ip or "0.0.0.0", # nosec B104
  946. bind_identity={
  947. "serial": self.target_printer_serial or self.serial,
  948. "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  949. "name": self.name,
  950. "version": "01.00.00.00",
  951. },
  952. )
  953. async def run_with_logging(coro, svc_name):
  954. try:
  955. await coro
  956. except Exception as e:
  957. logger.error("[VP %s] %s failed: %s", self.name, svc_name, e)
  958. self._tasks = []
  959. # SSDP for proxy
  960. proxy_serial = self.target_printer_serial or self.serial
  961. if self.remote_interface_ip:
  962. from backend.app.services.network_utils import find_interface_for_ip
  963. local_iface = find_interface_for_ip(self.target_printer_ip)
  964. if local_iface:
  965. self._ssdp_proxy = SSDPProxy(
  966. local_interface_ip=local_iface["ip"],
  967. remote_interface_ip=self.remote_interface_ip,
  968. target_printer_ip=self.target_printer_ip,
  969. name=self.name,
  970. )
  971. self._tasks.append(
  972. asyncio.create_task(
  973. run_with_logging(self._ssdp_proxy.start(), "SSDP Proxy"),
  974. name=f"vp_{self.id}_ssdp_proxy",
  975. )
  976. )
  977. else:
  978. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  979. else:
  980. self._start_fallback_ssdp(proxy_serial, run_with_logging)
  981. self._tasks.append(
  982. asyncio.create_task(
  983. run_with_logging(self._proxy.start(), "Proxy"),
  984. name=f"vp_{self.id}_proxy",
  985. )
  986. )
  987. def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
  988. """Start single-interface SSDP server as fallback for proxy mode."""
  989. self._ssdp = VirtualPrinterSSDPServer(
  990. name=f"{self.name} (Proxy)",
  991. serial=proxy_serial,
  992. model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  993. advertise_ip=self.bind_ip or "",
  994. bind_ip=self.bind_ip or "",
  995. )
  996. self._tasks.append(
  997. asyncio.create_task(
  998. run_with_logging(self._ssdp.start(), "SSDP"),
  999. name=f"vp_{self.id}_ssdp",
  1000. )
  1001. )
  1002. async def stop_proxy(self) -> None:
  1003. """Stop proxy mode services for this instance."""
  1004. if self._proxy:
  1005. await self._proxy.stop()
  1006. self._proxy = None
  1007. if self._ssdp:
  1008. await self._ssdp.stop()
  1009. self._ssdp = None
  1010. if self._ssdp_proxy:
  1011. await self._ssdp_proxy.stop()
  1012. self._ssdp_proxy = None
  1013. await self._cancel_tasks()
  1014. async def _cancel_tasks(self) -> None:
  1015. """Cancel all running tasks and wait for cleanup."""
  1016. for task in self._tasks:
  1017. task.cancel()
  1018. if self._tasks:
  1019. try:
  1020. await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
  1021. except TimeoutError:
  1022. pass
  1023. self._tasks = []
  1024. def get_status(self) -> dict:
  1025. """Get status for this instance."""
  1026. status: dict = {
  1027. "running": self.is_running,
  1028. "pending_files": len(self._pending_files),
  1029. }
  1030. if self.is_proxy and self._proxy:
  1031. status["proxy"] = self._proxy.get_status()
  1032. return status
  1033. class VirtualPrinterManager:
  1034. """Multi-instance virtual printer registry and orchestrator.
  1035. Every VP runs its own independent services on a dedicated bind IP.
  1036. """
  1037. def __init__(self):
  1038. self._session_factory: Callable | None = None
  1039. self._printer_manager: PrinterManager | None = None
  1040. self._instances: dict[int, VirtualPrinterInstance] = {}
  1041. # Serialize sync_from_db so concurrent PUT /vp/{id} calls can't
  1042. # race the start/stop sequence and leave duplicate sub-services
  1043. # bound to the same port. The lock is fine-grained enough that
  1044. # a single VP update completes in well under a second; if the
  1045. # user holds the lock with a long-running start they intended
  1046. # to anyway.
  1047. self._sync_lock = asyncio.Lock()
  1048. # Directories
  1049. self._base_dir = app_settings.base_dir / "virtual_printer"
  1050. # Ensure base directories exist
  1051. self._ensure_base_directories()
  1052. def _ensure_base_directories(self) -> None:
  1053. """Create base directories at startup."""
  1054. for dir_path in [self._base_dir, self._base_dir / "uploads", self._base_dir / "certs"]:
  1055. try:
  1056. dir_path.mkdir(parents=True, exist_ok=True)
  1057. except PermissionError:
  1058. logger.error(
  1059. f"Cannot create directory {dir_path}: Permission denied. "
  1060. f"For Docker: ensure the data volume is writable by the container user. "
  1061. f"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'"
  1062. )
  1063. def set_session_factory(self, session_factory: Callable) -> None:
  1064. """Set the database session factory."""
  1065. self._session_factory = session_factory
  1066. def set_printer_manager(self, printer_manager: "PrinterManager") -> None:
  1067. """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
  1068. self._printer_manager = printer_manager
  1069. def get_ca_certificate_info(self) -> dict:
  1070. """Return the shared virtual-printer CA certificate for slicer-trust import.
  1071. The CA is shared by every VP (one import covers all of them). It is
  1072. generated on demand here if no VP has triggered cert generation yet,
  1073. so the "copy/download certificate" UI works even before the first VP
  1074. is enabled.
  1075. """
  1076. certs_dir = self._base_dir / "certs"
  1077. cert_service = CertificateService(cert_dir=certs_dir, shared_ca_dir=certs_dir)
  1078. return cert_service.get_ca_certificate_info()
  1079. @property
  1080. def is_enabled(self) -> bool:
  1081. """Check if any virtual printer is running."""
  1082. return len(self._instances) > 0
  1083. async def sync_from_db(self) -> None:
  1084. """Load all VPs from DB, reconcile running state.
  1085. Serialised by ``self._sync_lock`` — concurrent PUT /vp/{id} routes
  1086. all call into this method; without the lock the start / stop
  1087. sequence races and can leave duplicate sub-services bound to the
  1088. same port or orphan still-running tasks.
  1089. """
  1090. if not self._session_factory:
  1091. logger.warning("Cannot sync virtual printers: no session factory")
  1092. return
  1093. async with self._sync_lock:
  1094. await self._sync_from_db_locked()
  1095. async def _sync_from_db_locked(self) -> None:
  1096. """Inner sync body — caller holds ``self._sync_lock``."""
  1097. from sqlalchemy import select
  1098. from backend.app.models.printer import Printer
  1099. from backend.app.models.virtual_printer import VirtualPrinter
  1100. async with self._session_factory() as db:
  1101. result = await db.execute(
  1102. select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position) # noqa: E712
  1103. )
  1104. enabled_vps = result.scalars().all()
  1105. # Stop instances that are no longer enabled or changed mode
  1106. enabled_ids = {vp.id for vp in enabled_vps}
  1107. for vp_id in list(self._instances.keys()):
  1108. if vp_id not in enabled_ids:
  1109. await self.remove_instance(vp_id)
  1110. # Look up printer IPs for proxy VPs
  1111. proxy_vps = [vp for vp in enabled_vps if vp.mode == "proxy"]
  1112. proxy_ips: dict[int, tuple[str, str]] = {}
  1113. if proxy_vps:
  1114. async with self._session_factory() as db:
  1115. for pvp in proxy_vps:
  1116. if pvp.target_printer_id:
  1117. result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))
  1118. printer = result.scalar_one_or_none()
  1119. if printer:
  1120. proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)
  1121. # Detect config changes on running instances and restart if needed
  1122. for vp in enabled_vps:
  1123. instance = self._instances.get(vp.id)
  1124. if not instance:
  1125. continue
  1126. # Proxy mode: detect target printer IP / serial changes from the
  1127. # DB lookup above. Without this branch a DHCP renewal that gives
  1128. # the target printer a new IP would leave the running proxy
  1129. # forwarding to the stale IP until the user manually toggles the
  1130. # VP. The same shape covers a target-side serial change.
  1131. proxy_target_changed = False
  1132. if vp.mode == "proxy":
  1133. fresh = proxy_ips.get(vp.id)
  1134. if fresh is not None:
  1135. fresh_ip, fresh_serial = fresh
  1136. if (
  1137. getattr(instance, "target_printer_ip", None) != fresh_ip
  1138. or getattr(instance, "target_printer_serial", None) != fresh_serial
  1139. ):
  1140. proxy_target_changed = True
  1141. # Normalize the DB value before comparing — a legacy `immediate`
  1142. # row read before the migration window finishes would otherwise
  1143. # trip the "changed" branch and bounce every VP at boot.
  1144. db_mode = normalize_vp_mode(vp.mode)
  1145. changed = (
  1146. instance.mode != db_mode
  1147. or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)
  1148. or instance.access_code != (vp.access_code or "")
  1149. or instance.bind_ip != (vp.bind_ip or "")
  1150. or instance.remote_interface_ip != (vp.remote_interface_ip or "")
  1151. or instance.target_printer_id != vp.target_printer_id
  1152. or instance.auto_dispatch != vp.auto_dispatch
  1153. # Queue-mode behaviour toggle — without it the running
  1154. # instance silently keeps the old value until process
  1155. # restart (#1552 follow-up family).
  1156. or instance.queue_force_color_match != vp.queue_force_color_match
  1157. or instance.gcode_injection != vp.gcode_injection
  1158. or proxy_target_changed
  1159. )
  1160. if changed:
  1161. logger.info(
  1162. "VP %s config changed (mode: %s→%s), restarting",
  1163. instance.name,
  1164. instance.mode,
  1165. vp.mode,
  1166. )
  1167. await self.remove_instance(vp.id)
  1168. # Start instances for all enabled VPs (skip already running)
  1169. for vp in enabled_vps:
  1170. if vp.id in self._instances:
  1171. continue
  1172. if vp.mode == "proxy":
  1173. ip_info = proxy_ips.get(vp.id)
  1174. if not ip_info:
  1175. logger.warning("Proxy VP %s: target printer not found, skipping", vp.name)
  1176. continue
  1177. target_ip, target_serial = ip_info
  1178. instance = VirtualPrinterInstance(
  1179. vp_id=vp.id,
  1180. name=vp.name,
  1181. mode=vp.mode,
  1182. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1183. access_code=vp.access_code or "",
  1184. serial_suffix=vp.serial_suffix,
  1185. target_printer_ip=target_ip,
  1186. target_printer_serial=target_serial,
  1187. auto_dispatch=vp.auto_dispatch,
  1188. bind_ip=vp.bind_ip or "",
  1189. remote_interface_ip=vp.remote_interface_ip or "",
  1190. tailscale_disabled=vp.tailscale_disabled,
  1191. base_dir=self._base_dir,
  1192. session_factory=self._session_factory,
  1193. )
  1194. self._instances[vp.id] = instance
  1195. await instance.start_proxy()
  1196. logger.info("Started proxy VP: %s → %s (bind=%s)", instance.name, target_ip, instance.bind_ip)
  1197. else:
  1198. instance = VirtualPrinterInstance(
  1199. vp_id=vp.id,
  1200. name=vp.name,
  1201. mode=vp.mode,
  1202. model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1203. access_code=vp.access_code or "",
  1204. serial_suffix=vp.serial_suffix,
  1205. target_printer_id=vp.target_printer_id,
  1206. auto_dispatch=vp.auto_dispatch,
  1207. queue_force_color_match=vp.queue_force_color_match,
  1208. gcode_injection=vp.gcode_injection,
  1209. bind_ip=vp.bind_ip or "",
  1210. remote_interface_ip=vp.remote_interface_ip or "",
  1211. tailscale_disabled=vp.tailscale_disabled,
  1212. base_dir=self._base_dir,
  1213. session_factory=self._session_factory,
  1214. printer_manager=self._printer_manager,
  1215. )
  1216. self._instances[vp.id] = instance
  1217. await instance.start_server()
  1218. logger.info("Started server-mode VP: %s on %s", instance.name, vp.bind_ip)
  1219. async def remove_instance(self, vp_id: int) -> None:
  1220. """Stop and remove a single VP instance."""
  1221. instance = self._instances.pop(vp_id, None)
  1222. if instance:
  1223. if instance.is_proxy:
  1224. await instance.stop_proxy()
  1225. else:
  1226. await instance.stop_server()
  1227. logger.info("Removed VP instance: %s", instance.name)
  1228. async def stop_all(self) -> None:
  1229. """Shutdown all virtual printer services."""
  1230. logger.info("Stopping all virtual printer services...")
  1231. for vp_id in list(self._instances.keys()):
  1232. await self.remove_instance(vp_id)
  1233. logger.info("All virtual printer services stopped")
  1234. def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:
  1235. """Get a running instance by ID."""
  1236. return self._instances.get(vp_id)
  1237. def get_all_status(self) -> list[dict]:
  1238. """Get status for all running instances."""
  1239. return [
  1240. {
  1241. "id": inst.id,
  1242. "name": inst.name,
  1243. "mode": inst.mode,
  1244. **inst.get_status(),
  1245. }
  1246. for inst in self._instances.values()
  1247. ]
  1248. # -- Legacy single-printer compat --
  1249. def get_status(self) -> dict:
  1250. """Get status for first virtual printer (backward compat)."""
  1251. if self._instances:
  1252. first = next(iter(self._instances.values()))
  1253. return {
  1254. "enabled": True,
  1255. "running": first.is_running,
  1256. "mode": first.mode,
  1257. "name": first.name,
  1258. "serial": first.serial,
  1259. "model": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1260. "model_name": VIRTUAL_PRINTER_MODELS.get(
  1261. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1262. first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
  1263. ),
  1264. "pending_files": first.get_status().get("pending_files", 0),
  1265. **({"target_printer_ip": first.target_printer_ip} if first.is_proxy else {}),
  1266. **({"proxy": first.get_status().get("proxy", {})} if first.is_proxy else {}),
  1267. }
  1268. return {
  1269. "enabled": False,
  1270. "running": False,
  1271. "mode": VP_MODE_ARCHIVE,
  1272. "name": "Bambuddy",
  1273. "serial": "",
  1274. "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
  1275. "model_name": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],
  1276. "pending_files": 0,
  1277. }
  1278. async def configure(
  1279. self,
  1280. enabled: bool,
  1281. access_code: str = "",
  1282. mode: str = VP_MODE_ARCHIVE,
  1283. model: str = "",
  1284. target_printer_ip: str = "",
  1285. target_printer_serial: str = "",
  1286. remote_interface_ip: str = "",
  1287. ) -> None:
  1288. """Legacy single-printer configure. Delegates to sync_from_db()."""
  1289. # This method is kept for backward compat with the settings endpoint.
  1290. # The actual work is done by sync_from_db() which reads from the DB.
  1291. await self.sync_from_db()
  1292. # Global instance
  1293. virtual_printer_manager = VirtualPrinterManager()