main.py 37 KB

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