manager.py 85 KB

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