main.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. import asyncio
  2. import logging
  3. from datetime import datetime
  4. from contextlib import asynccontextmanager
  5. from pathlib import Path
  6. from logging.handlers import RotatingFileHandler
  7. from fastapi import FastAPI
  8. # Configure logging for all modules - console + file
  9. log_format = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
  10. log_level = logging.INFO
  11. # Create root logger
  12. root_logger = logging.getLogger()
  13. root_logger.setLevel(log_level)
  14. # Console handler
  15. console_handler = logging.StreamHandler()
  16. console_handler.setLevel(log_level)
  17. console_handler.setFormatter(logging.Formatter(log_format))
  18. root_logger.addHandler(console_handler)
  19. # File handler - rotating log file (5MB max, keep 3 backups)
  20. log_file = Path(__file__).parent.parent.parent / "bambutrack.log"
  21. file_handler = RotatingFileHandler(
  22. log_file,
  23. maxBytes=5*1024*1024, # 5MB
  24. backupCount=3,
  25. encoding='utf-8'
  26. )
  27. file_handler.setLevel(log_level)
  28. file_handler.setFormatter(logging.Formatter(log_format))
  29. root_logger.addHandler(file_handler)
  30. logging.info(f"Logging to file: {log_file}")
  31. from fastapi.staticfiles import StaticFiles
  32. from fastapi.responses import FileResponse
  33. from backend.app.core.config import settings as app_settings
  34. from backend.app.core.database import init_db, async_session
  35. from backend.app.core.websocket import ws_manager
  36. from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue
  37. from backend.app.api.routes import settings as settings_routes
  38. from backend.app.services.printer_manager import (
  39. printer_manager,
  40. printer_state_to_dict,
  41. init_printer_connections,
  42. )
  43. from backend.app.services.print_scheduler import scheduler as print_scheduler
  44. from backend.app.services.bambu_mqtt import PrinterState
  45. from backend.app.services.archive import ArchiveService
  46. from backend.app.services.bambu_ftp import download_file_async
  47. from backend.app.services.smart_plug_manager import smart_plug_manager
  48. from backend.app.services.tasmota import tasmota_service
  49. from backend.app.models.smart_plug import SmartPlug
  50. # Track active prints: {(printer_id, filename): archive_id}
  51. _active_prints: dict[tuple[int, str], int] = {}
  52. # Track expected prints from reprint/scheduled (skip auto-archiving for these)
  53. # {(printer_id, filename): archive_id}
  54. _expected_prints: dict[tuple[int, str], int] = {}
  55. # Track starting energy for prints: {archive_id: starting_kwh}
  56. _print_energy_start: dict[int, float] = {}
  57. def register_expected_print(printer_id: int, filename: str, archive_id: int):
  58. """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
  59. # Store with multiple filename variations to catch different naming patterns
  60. _expected_prints[(printer_id, filename)] = archive_id
  61. # Also store without .3mf extension if present
  62. if filename.endswith(".3mf"):
  63. base = filename[:-4]
  64. _expected_prints[(printer_id, base)] = archive_id
  65. _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
  66. logging.getLogger(__name__).info(
  67. f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}"
  68. )
  69. _last_status_broadcast: dict[int, str] = {}
  70. async def on_printer_status_change(printer_id: int, state: PrinterState):
  71. """Handle printer status changes - broadcast via WebSocket."""
  72. # Only broadcast if something meaningful changed (reduce WebSocket spam)
  73. status_key = f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}"
  74. if _last_status_broadcast.get(printer_id) == status_key:
  75. return # No change, skip broadcast
  76. _last_status_broadcast[printer_id] = status_key
  77. await ws_manager.send_printer_status(
  78. printer_id,
  79. printer_state_to_dict(state, printer_id),
  80. )
  81. async def on_print_start(printer_id: int, data: dict):
  82. """Handle print start - archive the 3MF file immediately."""
  83. import logging
  84. logger = logging.getLogger(__name__)
  85. await ws_manager.send_print_start(printer_id, data)
  86. async with async_session() as db:
  87. from backend.app.models.printer import Printer
  88. from backend.app.services.bambu_ftp import list_files_async
  89. from sqlalchemy import select
  90. result = await db.execute(
  91. select(Printer).where(Printer.id == printer_id)
  92. )
  93. printer = result.scalar_one_or_none()
  94. if not printer or not printer.auto_archive:
  95. return
  96. # Get the filename and subtask_name
  97. filename = data.get("filename", "")
  98. subtask_name = data.get("subtask_name", "")
  99. logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}")
  100. if not filename and not subtask_name:
  101. return
  102. # Check if this is an expected print from reprint/scheduled
  103. # Build list of possible keys to check
  104. expected_keys = []
  105. if subtask_name:
  106. expected_keys.append((printer_id, subtask_name))
  107. expected_keys.append((printer_id, f"{subtask_name}.3mf"))
  108. expected_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
  109. if filename:
  110. fname = filename.split("/")[-1] if "/" in filename else filename
  111. expected_keys.append((printer_id, fname))
  112. # Strip extensions to match
  113. base = fname.replace(".gcode", "").replace(".3mf", "")
  114. expected_keys.append((printer_id, base))
  115. expected_keys.append((printer_id, f"{base}.3mf"))
  116. expected_archive_id = None
  117. for key in expected_keys:
  118. expected_archive_id = _expected_prints.pop(key, None)
  119. if expected_archive_id:
  120. # Clean up other possible keys for this print
  121. for other_key in expected_keys:
  122. _expected_prints.pop(other_key, None)
  123. break
  124. if expected_archive_id:
  125. # This is a reprint/scheduled print - use existing archive, don't create new one
  126. logger.info(f"Using expected archive {expected_archive_id} for print (skipping duplicate)")
  127. from backend.app.models.archive import PrintArchive
  128. from datetime import datetime
  129. result = await db.execute(
  130. select(PrintArchive).where(PrintArchive.id == expected_archive_id)
  131. )
  132. archive = result.scalar_one_or_none()
  133. if archive:
  134. # Update archive status to printing
  135. archive.status = "printing"
  136. archive.started_at = datetime.now()
  137. await db.commit()
  138. # Track as active print
  139. _active_prints[(printer_id, archive.filename)] = archive.id
  140. if subtask_name:
  141. _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
  142. # Set up energy tracking
  143. try:
  144. plug_result = await db.execute(
  145. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  146. )
  147. plug = plug_result.scalar_one_or_none()
  148. if plug:
  149. energy = await tasmota_service.get_energy(plug)
  150. if energy and energy.get("total") is not None:
  151. _print_energy_start[archive.id] = energy["total"]
  152. logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
  153. except Exception as e:
  154. logger.warning(f"Failed to record starting energy: {e}")
  155. await ws_manager.send_archive_updated({
  156. "id": archive.id,
  157. "status": "printing",
  158. })
  159. # Smart plug automation for expected prints too
  160. try:
  161. await smart_plug_manager.on_print_start(printer_id, db)
  162. except Exception as e:
  163. logger.warning(f"Smart plug on_print_start failed: {e}")
  164. return # Skip creating a new archive
  165. # Check if there's already a "printing" archive for this printer/file
  166. # This prevents duplicates when backend restarts during an active print
  167. from backend.app.models.archive import PrintArchive
  168. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  169. existing = await db.execute(
  170. select(PrintArchive)
  171. .where(PrintArchive.printer_id == printer_id)
  172. .where(PrintArchive.status == "printing")
  173. .where(PrintArchive.print_name.ilike(f"%{check_name}%"))
  174. .order_by(PrintArchive.created_at.desc())
  175. .limit(1)
  176. )
  177. existing_archive = existing.scalar_one_or_none()
  178. if existing_archive:
  179. logger.info(f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}")
  180. # Track this as the active print
  181. _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
  182. # Also set up energy tracking if not already tracked
  183. if existing_archive.id not in _print_energy_start:
  184. try:
  185. plug_result = await db.execute(
  186. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  187. )
  188. plug = plug_result.scalar_one_or_none()
  189. if plug:
  190. energy = await tasmota_service.get_energy(plug)
  191. if energy and energy.get("total") is not None:
  192. _print_energy_start[existing_archive.id] = energy["total"]
  193. logger.info(f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh")
  194. except Exception as e:
  195. logger.warning(f"Failed to record starting energy for existing archive: {e}")
  196. return
  197. # Build list of possible 3MF filenames to try
  198. possible_names = []
  199. # Bambu printers typically store files as "Name.gcode.3mf"
  200. # The subtask_name is usually the best source for the filename
  201. if subtask_name:
  202. # Try common Bambu naming patterns
  203. possible_names.append(f"{subtask_name}.gcode.3mf")
  204. possible_names.append(f"{subtask_name}.3mf")
  205. # Try original filename with .3mf extension
  206. if filename:
  207. # Extract just the filename part, not the full path
  208. fname = filename.split("/")[-1] if "/" in filename else filename
  209. if fname.endswith(".3mf"):
  210. possible_names.append(fname)
  211. elif fname.endswith(".gcode"):
  212. base = fname.rsplit(".", 1)[0]
  213. possible_names.append(f"{base}.gcode.3mf")
  214. possible_names.append(f"{base}.3mf")
  215. else:
  216. possible_names.append(f"{fname}.gcode.3mf")
  217. possible_names.append(f"{fname}.3mf")
  218. # Remove duplicates while preserving order
  219. seen = set()
  220. possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
  221. logger.info(f"Trying filenames: {possible_names}")
  222. # Try to find and download the 3MF file
  223. temp_path = None
  224. downloaded_filename = None
  225. for try_filename in possible_names:
  226. if not try_filename.endswith(".3mf"):
  227. continue
  228. remote_paths = [
  229. f"/cache/{try_filename}",
  230. f"/model/{try_filename}",
  231. f"/{try_filename}",
  232. ]
  233. temp_path = app_settings.archive_dir / "temp" / try_filename
  234. temp_path.parent.mkdir(parents=True, exist_ok=True)
  235. for remote_path in remote_paths:
  236. logger.debug(f"Trying FTP download: {remote_path}")
  237. try:
  238. if await download_file_async(
  239. printer.ip_address,
  240. printer.access_code,
  241. remote_path,
  242. temp_path,
  243. ):
  244. downloaded_filename = try_filename
  245. logger.info(f"Downloaded: {remote_path}")
  246. break
  247. except Exception as e:
  248. logger.debug(f"FTP download failed for {remote_path}: {e}")
  249. if downloaded_filename:
  250. break
  251. # If still not found, try listing /cache to find matching file
  252. if not downloaded_filename and (filename or subtask_name):
  253. search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
  254. try:
  255. cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
  256. for f in cache_files:
  257. if f.get("is_directory"):
  258. continue
  259. fname = f.get("name", "")
  260. if fname.endswith(".3mf") and search_term in fname.lower():
  261. temp_path = app_settings.archive_dir / "temp" / fname
  262. temp_path.parent.mkdir(parents=True, exist_ok=True)
  263. if await download_file_async(
  264. printer.ip_address,
  265. printer.access_code,
  266. f"/cache/{fname}",
  267. temp_path,
  268. ):
  269. downloaded_filename = fname
  270. logger.info(f"Found and downloaded from cache: {fname}")
  271. break
  272. except Exception as e:
  273. logger.warning(f"Failed to list cache: {e}")
  274. if not downloaded_filename or not temp_path:
  275. logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
  276. return
  277. try:
  278. # Archive the file with status "printing"
  279. service = ArchiveService(db)
  280. archive = await service.archive_print(
  281. printer_id=printer_id,
  282. source_file=temp_path,
  283. print_data={**data, "status": "printing"},
  284. )
  285. if archive:
  286. # Track this active print (use both original filename and downloaded filename)
  287. _active_prints[(printer_id, downloaded_filename)] = archive.id
  288. if filename and filename != downloaded_filename:
  289. _active_prints[(printer_id, filename)] = archive.id
  290. if subtask_name:
  291. _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
  292. logger.info(f"Created archive {archive.id} for {downloaded_filename}")
  293. # Record starting energy from smart plug if available
  294. try:
  295. plug_result = await db.execute(
  296. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  297. )
  298. plug = plug_result.scalar_one_or_none()
  299. if plug:
  300. energy = await tasmota_service.get_energy(plug)
  301. if energy and energy.get("total") is not None:
  302. _print_energy_start[archive.id] = energy["total"]
  303. logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
  304. except Exception as e:
  305. logger.warning(f"Failed to record starting energy: {e}")
  306. await ws_manager.send_archive_created({
  307. "id": archive.id,
  308. "printer_id": archive.printer_id,
  309. "filename": archive.filename,
  310. "print_name": archive.print_name,
  311. "status": archive.status,
  312. })
  313. finally:
  314. if temp_path and temp_path.exists():
  315. temp_path.unlink()
  316. # Smart plug automation: turn on plug when print starts
  317. try:
  318. async with async_session() as db:
  319. await smart_plug_manager.on_print_start(printer_id, db)
  320. except Exception as e:
  321. import logging
  322. logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
  323. async def on_print_complete(printer_id: int, data: dict):
  324. """Handle print completion - update the archive status."""
  325. import logging
  326. logger = logging.getLogger(__name__)
  327. await ws_manager.send_print_complete(printer_id, data)
  328. filename = data.get("filename", "")
  329. if not filename:
  330. return
  331. logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
  332. # Build list of possible keys to try
  333. possible_keys = []
  334. if filename.endswith(".3mf"):
  335. possible_keys.append((printer_id, filename))
  336. elif filename.endswith(".gcode"):
  337. base_name = filename.rsplit(".", 1)[0]
  338. possible_keys.append((printer_id, f"{base_name}.3mf"))
  339. possible_keys.append((printer_id, filename))
  340. else:
  341. possible_keys.append((printer_id, f"{filename}.3mf"))
  342. possible_keys.append((printer_id, filename))
  343. # Find the archive for this print
  344. archive_id = None
  345. for key in possible_keys:
  346. archive_id = _active_prints.pop(key, None)
  347. if archive_id:
  348. # Also clean up any other keys pointing to this archive
  349. keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
  350. for k in keys_to_remove:
  351. _active_prints.pop(k, None)
  352. break
  353. if not archive_id:
  354. # Try to find by filename if not tracked (for prints started before app)
  355. async with async_session() as db:
  356. from backend.app.models.archive import PrintArchive
  357. from sqlalchemy import select
  358. result = await db.execute(
  359. select(PrintArchive)
  360. .where(PrintArchive.printer_id == printer_id)
  361. .where(PrintArchive.filename == filename)
  362. .where(PrintArchive.status == "printing")
  363. .order_by(PrintArchive.created_at.desc())
  364. .limit(1)
  365. )
  366. archive = result.scalar_one_or_none()
  367. if archive:
  368. archive_id = archive.id
  369. if not archive_id:
  370. return
  371. # Update archive status
  372. async with async_session() as db:
  373. service = ArchiveService(db)
  374. status = data.get("status", "completed")
  375. await service.update_archive_status(
  376. archive_id,
  377. status=status,
  378. completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None,
  379. )
  380. await ws_manager.send_archive_updated({
  381. "id": archive_id,
  382. "status": status,
  383. })
  384. # Calculate energy used for this print
  385. try:
  386. starting_kwh = _print_energy_start.pop(archive_id, None)
  387. if starting_kwh is not None:
  388. async with async_session() as db:
  389. # Get smart plug for this printer
  390. plug_result = await db.execute(
  391. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  392. )
  393. plug = plug_result.scalar_one_or_none()
  394. if plug:
  395. energy = await tasmota_service.get_energy(plug)
  396. if energy and energy.get("total") is not None:
  397. ending_kwh = energy["total"]
  398. energy_used = round(ending_kwh - starting_kwh, 4)
  399. # Get energy cost per kWh from settings (default to 0.15)
  400. from backend.app.api.routes.settings import get_setting
  401. energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
  402. cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
  403. energy_cost = round(energy_used * cost_per_kwh, 2)
  404. # Update archive with energy data
  405. from backend.app.models.archive import PrintArchive
  406. result = await db.execute(
  407. select(PrintArchive).where(PrintArchive.id == archive_id)
  408. )
  409. archive = result.scalar_one_or_none()
  410. if archive:
  411. archive.energy_kwh = energy_used
  412. archive.energy_cost = energy_cost
  413. await db.commit()
  414. logger.info(f"Recorded energy for archive {archive_id}: {energy_used} kWh (${energy_cost})")
  415. except Exception as e:
  416. import logging
  417. logging.getLogger(__name__).warning(f"Failed to calculate energy: {e}")
  418. # Capture finish photo from printer camera
  419. try:
  420. async with async_session() as db:
  421. # Check if finish photo capture is enabled
  422. from backend.app.api.routes.settings import get_setting
  423. capture_enabled = await get_setting(db, "capture_finish_photo")
  424. if capture_enabled is None or capture_enabled.lower() == "true":
  425. # Get printer details
  426. from backend.app.models.printer import Printer
  427. from sqlalchemy import select
  428. result = await db.execute(
  429. select(Printer).where(Printer.id == printer_id)
  430. )
  431. printer = result.scalar_one_or_none()
  432. if printer and archive_id:
  433. # Get archive to find its directory
  434. from backend.app.models.archive import PrintArchive
  435. result = await db.execute(
  436. select(PrintArchive).where(PrintArchive.id == archive_id)
  437. )
  438. archive = result.scalar_one_or_none()
  439. if archive:
  440. from backend.app.services.camera import capture_finish_photo
  441. from pathlib import Path
  442. archive_dir = app_settings.base_dir / Path(archive.file_path).parent
  443. photo_filename = await capture_finish_photo(
  444. printer_id=printer_id,
  445. ip_address=printer.ip_address,
  446. access_code=printer.access_code,
  447. model=printer.model,
  448. archive_dir=archive_dir,
  449. )
  450. if photo_filename:
  451. # Add photo to archive's photos list
  452. photos = archive.photos or []
  453. photos.append(photo_filename)
  454. archive.photos = photos
  455. await db.commit()
  456. logger.info(f"Added finish photo to archive {archive_id}: {photo_filename}")
  457. except Exception as e:
  458. import logging
  459. logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
  460. # Smart plug automation: schedule turn off when print completes
  461. try:
  462. async with async_session() as db:
  463. status = data.get("status", "completed")
  464. await smart_plug_manager.on_print_complete(printer_id, status, db)
  465. except Exception as e:
  466. import logging
  467. logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
  468. # Update queue item if this was a scheduled print
  469. try:
  470. async with async_session() as db:
  471. from backend.app.models.print_queue import PrintQueueItem
  472. from backend.app.models.smart_plug import SmartPlug
  473. from backend.app.services.tasmota import tasmota_service
  474. result = await db.execute(
  475. select(PrintQueueItem)
  476. .where(PrintQueueItem.printer_id == printer_id)
  477. .where(PrintQueueItem.status == "printing")
  478. )
  479. queue_item = result.scalar_one_or_none()
  480. if queue_item:
  481. status = data.get("status", "completed")
  482. queue_item.status = status
  483. queue_item.completed_at = datetime.now()
  484. await db.commit()
  485. logger.info(f"Updated queue item {queue_item.id} status to {status}")
  486. # Handle auto_off_after - power off printer if requested (after cooldown)
  487. if queue_item.auto_off_after:
  488. result = await db.execute(
  489. select(SmartPlug).where(SmartPlug.printer_id == printer_id)
  490. )
  491. plug = result.scalar_one_or_none()
  492. if plug and plug.enabled:
  493. logger.info(f"Auto-off requested for printer {printer_id}, waiting for cooldown...")
  494. async def cooldown_and_poweroff(pid: int, plug_id: int):
  495. # Wait for nozzle to cool down
  496. await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
  497. # Re-fetch plug in new session
  498. async with async_session() as new_db:
  499. result = await new_db.execute(
  500. select(SmartPlug).where(SmartPlug.id == plug_id)
  501. )
  502. p = result.scalar_one_or_none()
  503. if p and p.enabled:
  504. success = await tasmota_service.turn_off(p)
  505. if success:
  506. logger.info(f"Powered off printer {pid} via smart plug '{p.name}'")
  507. else:
  508. logger.warning(f"Failed to power off printer {pid} via smart plug")
  509. asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
  510. except Exception as e:
  511. import logging
  512. logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
  513. @asynccontextmanager
  514. async def lifespan(app: FastAPI):
  515. # Startup
  516. await init_db()
  517. # Set up printer manager callbacks
  518. loop = asyncio.get_event_loop()
  519. printer_manager.set_event_loop(loop)
  520. printer_manager.set_status_change_callback(on_printer_status_change)
  521. printer_manager.set_print_start_callback(on_print_start)
  522. printer_manager.set_print_complete_callback(on_print_complete)
  523. # Connect to all active printers
  524. async with async_session() as db:
  525. await init_printer_connections(db)
  526. # Start the print scheduler
  527. asyncio.create_task(print_scheduler.run())
  528. yield
  529. # Shutdown
  530. print_scheduler.stop()
  531. printer_manager.disconnect_all()
  532. app = FastAPI(
  533. title=app_settings.app_name,
  534. description="Archive and manage Bambu Lab 3MF files",
  535. version="0.1.2",
  536. lifespan=lifespan,
  537. )
  538. # API routes
  539. app.include_router(printers.router, prefix=app_settings.api_prefix)
  540. app.include_router(archives.router, prefix=app_settings.api_prefix)
  541. app.include_router(filaments.router, prefix=app_settings.api_prefix)
  542. app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
  543. app.include_router(cloud.router, prefix=app_settings.api_prefix)
  544. app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
  545. app.include_router(print_queue.router, prefix=app_settings.api_prefix)
  546. app.include_router(websocket.router, prefix=app_settings.api_prefix)
  547. # Serve static files (React build)
  548. if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
  549. app.mount(
  550. "/assets",
  551. StaticFiles(directory=app_settings.static_dir / "assets"),
  552. name="assets",
  553. )
  554. if (app_settings.static_dir / "img").exists():
  555. app.mount(
  556. "/img",
  557. StaticFiles(directory=app_settings.static_dir / "img"),
  558. name="img",
  559. )
  560. @app.get("/")
  561. async def serve_frontend():
  562. """Serve the React frontend."""
  563. index_file = app_settings.static_dir / "index.html"
  564. if index_file.exists():
  565. return FileResponse(index_file)
  566. return {
  567. "message": "BambuTrack API",
  568. "docs": "/docs",
  569. "frontend": "Build and place React app in /static directory",
  570. }
  571. @app.get("/health")
  572. async def health_check():
  573. """Health check endpoint."""
  574. return {"status": "healthy"}
  575. # Catch-all route for React Router (must be last)
  576. @app.get("/{full_path:path}")
  577. async def serve_spa(full_path: str):
  578. """Serve React app for client-side routing."""
  579. # Don't intercept API routes
  580. if full_path.startswith("api/"):
  581. return {"error": "Not found"}
  582. index_file = app_settings.static_dir / "index.html"
  583. if index_file.exists():
  584. return FileResponse(index_file)
  585. return {"error": "Frontend not built"}