main.py 28 KB

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