main.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import asyncio
  2. import logging
  3. from datetime import datetime
  4. from contextlib import asynccontextmanager
  5. from pathlib import Path
  6. from fastapi import FastAPI
  7. # Configure logging for all modules
  8. logging.basicConfig(
  9. level=logging.INFO,
  10. format='%(asctime)s %(levelname)s [%(name)s] %(message)s'
  11. )
  12. from fastapi.staticfiles import StaticFiles
  13. from fastapi.responses import FileResponse
  14. from backend.app.core.config import settings as app_settings
  15. from backend.app.core.database import init_db, async_session
  16. from backend.app.core.websocket import ws_manager
  17. from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs
  18. from backend.app.api.routes import settings as settings_routes
  19. from backend.app.services.printer_manager import (
  20. printer_manager,
  21. printer_state_to_dict,
  22. init_printer_connections,
  23. )
  24. from backend.app.services.bambu_mqtt import PrinterState
  25. from backend.app.services.archive import ArchiveService
  26. from backend.app.services.bambu_ftp import download_file_async
  27. from backend.app.services.smart_plug_manager import smart_plug_manager
  28. # Track active prints: {(printer_id, filename): archive_id}
  29. _active_prints: dict[tuple[int, str], int] = {}
  30. async def on_printer_status_change(printer_id: int, state: PrinterState):
  31. """Handle printer status changes - broadcast via WebSocket."""
  32. await ws_manager.send_printer_status(
  33. printer_id,
  34. printer_state_to_dict(state, printer_id),
  35. )
  36. async def on_print_start(printer_id: int, data: dict):
  37. """Handle print start - archive the 3MF file immediately."""
  38. import logging
  39. logger = logging.getLogger(__name__)
  40. await ws_manager.send_print_start(printer_id, data)
  41. async with async_session() as db:
  42. from backend.app.models.printer import Printer
  43. from backend.app.services.bambu_ftp import list_files_async
  44. from sqlalchemy import select
  45. result = await db.execute(
  46. select(Printer).where(Printer.id == printer_id)
  47. )
  48. printer = result.scalar_one_or_none()
  49. if not printer or not printer.auto_archive:
  50. return
  51. # Get the filename and subtask_name
  52. filename = data.get("filename", "")
  53. subtask_name = data.get("subtask_name", "")
  54. logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}")
  55. if not filename and not subtask_name:
  56. return
  57. # Build list of possible 3MF filenames to try
  58. possible_names = []
  59. # Bambu printers typically store files as "Name.gcode.3mf"
  60. # The subtask_name is usually the best source for the filename
  61. if subtask_name:
  62. # Try common Bambu naming patterns
  63. possible_names.append(f"{subtask_name}.gcode.3mf")
  64. possible_names.append(f"{subtask_name}.3mf")
  65. # Try original filename with .3mf extension
  66. if filename:
  67. # Extract just the filename part, not the full path
  68. fname = filename.split("/")[-1] if "/" in filename else filename
  69. if fname.endswith(".3mf"):
  70. possible_names.append(fname)
  71. elif fname.endswith(".gcode"):
  72. base = fname.rsplit(".", 1)[0]
  73. possible_names.append(f"{base}.gcode.3mf")
  74. possible_names.append(f"{base}.3mf")
  75. else:
  76. possible_names.append(f"{fname}.gcode.3mf")
  77. possible_names.append(f"{fname}.3mf")
  78. # Remove duplicates while preserving order
  79. seen = set()
  80. possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
  81. logger.info(f"Trying filenames: {possible_names}")
  82. # Try to find and download the 3MF file
  83. temp_path = None
  84. downloaded_filename = None
  85. for try_filename in possible_names:
  86. if not try_filename.endswith(".3mf"):
  87. continue
  88. remote_paths = [
  89. f"/cache/{try_filename}",
  90. f"/model/{try_filename}",
  91. f"/{try_filename}",
  92. ]
  93. temp_path = app_settings.archive_dir / "temp" / try_filename
  94. temp_path.parent.mkdir(parents=True, exist_ok=True)
  95. for remote_path in remote_paths:
  96. logger.debug(f"Trying FTP download: {remote_path}")
  97. try:
  98. if await download_file_async(
  99. printer.ip_address,
  100. printer.access_code,
  101. remote_path,
  102. temp_path,
  103. ):
  104. downloaded_filename = try_filename
  105. logger.info(f"Downloaded: {remote_path}")
  106. break
  107. except Exception as e:
  108. logger.debug(f"FTP download failed for {remote_path}: {e}")
  109. if downloaded_filename:
  110. break
  111. # If still not found, try listing /cache to find matching file
  112. if not downloaded_filename and (filename or subtask_name):
  113. search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
  114. try:
  115. cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
  116. for f in cache_files:
  117. if f.get("is_directory"):
  118. continue
  119. fname = f.get("name", "")
  120. if fname.endswith(".3mf") and search_term in fname.lower():
  121. temp_path = app_settings.archive_dir / "temp" / fname
  122. temp_path.parent.mkdir(parents=True, exist_ok=True)
  123. if await download_file_async(
  124. printer.ip_address,
  125. printer.access_code,
  126. f"/cache/{fname}",
  127. temp_path,
  128. ):
  129. downloaded_filename = fname
  130. logger.info(f"Found and downloaded from cache: {fname}")
  131. break
  132. except Exception as e:
  133. logger.warning(f"Failed to list cache: {e}")
  134. if not downloaded_filename or not temp_path:
  135. logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
  136. return
  137. try:
  138. # Archive the file with status "printing"
  139. service = ArchiveService(db)
  140. archive = await service.archive_print(
  141. printer_id=printer_id,
  142. source_file=temp_path,
  143. print_data={**data, "status": "printing"},
  144. )
  145. if archive:
  146. # Track this active print (use both original filename and downloaded filename)
  147. _active_prints[(printer_id, downloaded_filename)] = archive.id
  148. if filename and filename != downloaded_filename:
  149. _active_prints[(printer_id, filename)] = archive.id
  150. if subtask_name:
  151. _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
  152. logger.info(f"Created archive {archive.id} for {downloaded_filename}")
  153. await ws_manager.send_archive_created({
  154. "id": archive.id,
  155. "printer_id": archive.printer_id,
  156. "filename": archive.filename,
  157. "print_name": archive.print_name,
  158. "status": archive.status,
  159. })
  160. finally:
  161. if temp_path and temp_path.exists():
  162. temp_path.unlink()
  163. # Smart plug automation: turn on plug when print starts
  164. try:
  165. async with async_session() as db:
  166. await smart_plug_manager.on_print_start(printer_id, db)
  167. except Exception as e:
  168. import logging
  169. logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
  170. async def on_print_complete(printer_id: int, data: dict):
  171. """Handle print completion - update the archive status."""
  172. import logging
  173. logger = logging.getLogger(__name__)
  174. await ws_manager.send_print_complete(printer_id, data)
  175. filename = data.get("filename", "")
  176. if not filename:
  177. return
  178. logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
  179. # Build list of possible keys to try
  180. possible_keys = []
  181. if filename.endswith(".3mf"):
  182. possible_keys.append((printer_id, filename))
  183. elif filename.endswith(".gcode"):
  184. base_name = filename.rsplit(".", 1)[0]
  185. possible_keys.append((printer_id, f"{base_name}.3mf"))
  186. possible_keys.append((printer_id, filename))
  187. else:
  188. possible_keys.append((printer_id, f"{filename}.3mf"))
  189. possible_keys.append((printer_id, filename))
  190. # Find the archive for this print
  191. archive_id = None
  192. for key in possible_keys:
  193. archive_id = _active_prints.pop(key, None)
  194. if archive_id:
  195. # Also clean up any other keys pointing to this archive
  196. keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
  197. for k in keys_to_remove:
  198. _active_prints.pop(k, None)
  199. break
  200. if not archive_id:
  201. # Try to find by filename if not tracked (for prints started before app)
  202. async with async_session() as db:
  203. from backend.app.models.archive import PrintArchive
  204. from sqlalchemy import select
  205. result = await db.execute(
  206. select(PrintArchive)
  207. .where(PrintArchive.printer_id == printer_id)
  208. .where(PrintArchive.filename == filename)
  209. .where(PrintArchive.status == "printing")
  210. .order_by(PrintArchive.created_at.desc())
  211. .limit(1)
  212. )
  213. archive = result.scalar_one_or_none()
  214. if archive:
  215. archive_id = archive.id
  216. if not archive_id:
  217. return
  218. # Update archive status
  219. async with async_session() as db:
  220. service = ArchiveService(db)
  221. status = data.get("status", "completed")
  222. await service.update_archive_status(
  223. archive_id,
  224. status=status,
  225. completed_at=datetime.now() if status in ("completed", "failed") else None,
  226. )
  227. await ws_manager.send_archive_updated({
  228. "id": archive_id,
  229. "status": status,
  230. })
  231. # Capture finish photo from printer camera
  232. try:
  233. async with async_session() as db:
  234. # Check if finish photo capture is enabled
  235. from backend.app.api.routes.settings import get_setting
  236. capture_enabled = await get_setting(db, "capture_finish_photo")
  237. if capture_enabled is None or capture_enabled.lower() == "true":
  238. # Get printer details
  239. from backend.app.models.printer import Printer
  240. from sqlalchemy import select
  241. result = await db.execute(
  242. select(Printer).where(Printer.id == printer_id)
  243. )
  244. printer = result.scalar_one_or_none()
  245. if printer and archive_id:
  246. # Get archive to find its directory
  247. from backend.app.models.archive import PrintArchive
  248. result = await db.execute(
  249. select(PrintArchive).where(PrintArchive.id == archive_id)
  250. )
  251. archive = result.scalar_one_or_none()
  252. if archive:
  253. from backend.app.services.camera import capture_finish_photo
  254. from pathlib import Path
  255. archive_dir = app_settings.base_dir / Path(archive.file_path).parent
  256. photo_filename = await capture_finish_photo(
  257. printer_id=printer_id,
  258. ip_address=printer.ip_address,
  259. access_code=printer.access_code,
  260. model=printer.model,
  261. archive_dir=archive_dir,
  262. )
  263. if photo_filename:
  264. # Add photo to archive's photos list
  265. photos = archive.photos or []
  266. photos.append(photo_filename)
  267. archive.photos = photos
  268. await db.commit()
  269. logger.info(f"Added finish photo to archive {archive_id}: {photo_filename}")
  270. except Exception as e:
  271. import logging
  272. logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
  273. # Smart plug automation: schedule turn off when print completes
  274. try:
  275. async with async_session() as db:
  276. status = data.get("status", "completed")
  277. await smart_plug_manager.on_print_complete(printer_id, status, db)
  278. except Exception as e:
  279. import logging
  280. logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
  281. @asynccontextmanager
  282. async def lifespan(app: FastAPI):
  283. # Startup
  284. await init_db()
  285. # Set up printer manager callbacks
  286. loop = asyncio.get_event_loop()
  287. printer_manager.set_event_loop(loop)
  288. printer_manager.set_status_change_callback(on_printer_status_change)
  289. printer_manager.set_print_start_callback(on_print_start)
  290. printer_manager.set_print_complete_callback(on_print_complete)
  291. # Connect to all active printers
  292. async with async_session() as db:
  293. await init_printer_connections(db)
  294. yield
  295. # Shutdown
  296. printer_manager.disconnect_all()
  297. app = FastAPI(
  298. title=app_settings.app_name,
  299. description="Archive and manage Bambu Lab 3MF files",
  300. version="0.1.2",
  301. lifespan=lifespan,
  302. )
  303. # API routes
  304. app.include_router(printers.router, prefix=app_settings.api_prefix)
  305. app.include_router(archives.router, prefix=app_settings.api_prefix)
  306. app.include_router(filaments.router, prefix=app_settings.api_prefix)
  307. app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
  308. app.include_router(cloud.router, prefix=app_settings.api_prefix)
  309. app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
  310. app.include_router(websocket.router, prefix=app_settings.api_prefix)
  311. # Serve static files (React build)
  312. if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
  313. app.mount(
  314. "/assets",
  315. StaticFiles(directory=app_settings.static_dir / "assets"),
  316. name="assets",
  317. )
  318. if (app_settings.static_dir / "img").exists():
  319. app.mount(
  320. "/img",
  321. StaticFiles(directory=app_settings.static_dir / "img"),
  322. name="img",
  323. )
  324. @app.get("/")
  325. async def serve_frontend():
  326. """Serve the React frontend."""
  327. index_file = app_settings.static_dir / "index.html"
  328. if index_file.exists():
  329. return FileResponse(index_file)
  330. return {
  331. "message": "BambuTrack API",
  332. "docs": "/docs",
  333. "frontend": "Build and place React app in /static directory",
  334. }
  335. @app.get("/health")
  336. async def health_check():
  337. """Health check endpoint."""
  338. return {"status": "healthy"}
  339. # Catch-all route for React Router (must be last)
  340. @app.get("/{full_path:path}")
  341. async def serve_spa(full_path: str):
  342. """Serve React app for client-side routing."""
  343. # Don't intercept API routes
  344. if full_path.startswith("api/"):
  345. return {"error": "Not found"}
  346. index_file = app_settings.static_dir / "index.html"
  347. if index_file.exists():
  348. return FileResponse(index_file)
  349. return {"error": "Frontend not built"}