main.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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. # Smart plug automation: schedule turn off when print completes
  232. try:
  233. async with async_session() as db:
  234. status = data.get("status", "completed")
  235. await smart_plug_manager.on_print_complete(printer_id, status, db)
  236. except Exception as e:
  237. import logging
  238. logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
  239. @asynccontextmanager
  240. async def lifespan(app: FastAPI):
  241. # Startup
  242. await init_db()
  243. # Set up printer manager callbacks
  244. loop = asyncio.get_event_loop()
  245. printer_manager.set_event_loop(loop)
  246. printer_manager.set_status_change_callback(on_printer_status_change)
  247. printer_manager.set_print_start_callback(on_print_start)
  248. printer_manager.set_print_complete_callback(on_print_complete)
  249. # Connect to all active printers
  250. async with async_session() as db:
  251. await init_printer_connections(db)
  252. yield
  253. # Shutdown
  254. printer_manager.disconnect_all()
  255. app = FastAPI(
  256. title=app_settings.app_name,
  257. description="Archive and manage Bambu Lab 3MF files",
  258. version="0.1.2",
  259. lifespan=lifespan,
  260. )
  261. # API routes
  262. app.include_router(printers.router, prefix=app_settings.api_prefix)
  263. app.include_router(archives.router, prefix=app_settings.api_prefix)
  264. app.include_router(filaments.router, prefix=app_settings.api_prefix)
  265. app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
  266. app.include_router(cloud.router, prefix=app_settings.api_prefix)
  267. app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
  268. app.include_router(websocket.router, prefix=app_settings.api_prefix)
  269. # Serve static files (React build)
  270. if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
  271. app.mount(
  272. "/assets",
  273. StaticFiles(directory=app_settings.static_dir / "assets"),
  274. name="assets",
  275. )
  276. if (app_settings.static_dir / "img").exists():
  277. app.mount(
  278. "/img",
  279. StaticFiles(directory=app_settings.static_dir / "img"),
  280. name="img",
  281. )
  282. @app.get("/")
  283. async def serve_frontend():
  284. """Serve the React frontend."""
  285. index_file = app_settings.static_dir / "index.html"
  286. if index_file.exists():
  287. return FileResponse(index_file)
  288. return {
  289. "message": "BambuTrack API",
  290. "docs": "/docs",
  291. "frontend": "Build and place React app in /static directory",
  292. }
  293. @app.get("/health")
  294. async def health_check():
  295. """Health check endpoint."""
  296. return {"status": "healthy"}
  297. # Catch-all route for React Router (must be last)
  298. @app.get("/{full_path:path}")
  299. async def serve_spa(full_path: str):
  300. """Serve React app for client-side routing."""
  301. # Don't intercept API routes
  302. if full_path.startswith("api/"):
  303. return {"error": "Not found"}
  304. index_file = app_settings.static_dir / "index.html"
  305. if index_file.exists():
  306. return FileResponse(index_file)
  307. return {"error": "Frontend not built"}