manager.py 54 KB

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