main.py 12 KB

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