main.py 12 KB

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