manager.py 57 KB

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