main.py 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605
  1. import asyncio
  2. import logging
  3. from contextlib import asynccontextmanager
  4. from datetime import datetime, timedelta
  5. from logging.handlers import RotatingFileHandler
  6. from fastapi import FastAPI
  7. # Import settings first for logging configuration
  8. from backend.app.core.config import APP_VERSION, settings as app_settings
  9. # Configure logging based on settings
  10. # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
  11. log_level_str = "DEBUG" if app_settings.debug else app_settings.log_level.upper()
  12. log_level = getattr(logging, log_level_str, logging.INFO)
  13. log_format = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
  14. # Create root logger
  15. root_logger = logging.getLogger()
  16. root_logger.setLevel(log_level)
  17. # Console handler - always enabled
  18. console_handler = logging.StreamHandler()
  19. console_handler.setLevel(log_level)
  20. console_handler.setFormatter(logging.Formatter(log_format))
  21. root_logger.addHandler(console_handler)
  22. # File handler - only in production or if explicitly enabled
  23. if app_settings.log_to_file:
  24. log_file = app_settings.log_dir / "bambuddy.log"
  25. file_handler = RotatingFileHandler(
  26. log_file,
  27. maxBytes=5 * 1024 * 1024, # 5MB
  28. backupCount=3,
  29. encoding="utf-8",
  30. )
  31. file_handler.setLevel(log_level)
  32. file_handler.setFormatter(logging.Formatter(log_format))
  33. root_logger.addHandler(file_handler)
  34. logging.info(f"Logging to file: {log_file}")
  35. # Reduce noise from third-party libraries in production
  36. if not app_settings.debug:
  37. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  38. logging.getLogger("httpcore").setLevel(logging.WARNING)
  39. logging.getLogger("httpx").setLevel(logging.WARNING)
  40. logging.info(f"Bambuddy starting - debug={app_settings.debug}, log_level={log_level_str}")
  41. from fastapi.responses import FileResponse
  42. from fastapi.staticfiles import StaticFiles
  43. from sqlalchemy import delete, or_, select
  44. from backend.app.api.routes import (
  45. ams_history,
  46. api_keys,
  47. archives,
  48. camera,
  49. cloud,
  50. external_links,
  51. filaments,
  52. kprofiles,
  53. maintenance,
  54. notification_templates,
  55. notifications,
  56. print_queue,
  57. printers,
  58. projects,
  59. settings as settings_routes,
  60. smart_plugs,
  61. spoolman,
  62. system,
  63. updates,
  64. webhook,
  65. websocket,
  66. )
  67. from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
  68. from backend.app.core.database import async_session, init_db
  69. from backend.app.core.websocket import ws_manager
  70. from backend.app.models.smart_plug import SmartPlug
  71. from backend.app.services.archive import ArchiveService
  72. from backend.app.services.bambu_ftp import download_file_async
  73. from backend.app.services.bambu_mqtt import PrinterState
  74. from backend.app.services.notification_service import notification_service
  75. from backend.app.services.print_scheduler import scheduler as print_scheduler
  76. from backend.app.services.printer_manager import (
  77. init_printer_connections,
  78. printer_manager,
  79. printer_state_to_dict,
  80. )
  81. from backend.app.services.smart_plug_manager import smart_plug_manager
  82. from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
  83. from backend.app.services.tasmota import tasmota_service
  84. from backend.app.services.telemetry import start_telemetry_loop
  85. # Track active prints: {(printer_id, filename): archive_id}
  86. _active_prints: dict[tuple[int, str], int] = {}
  87. # Track expected prints from reprint/scheduled (skip auto-archiving for these)
  88. # {(printer_id, filename): archive_id}
  89. _expected_prints: dict[tuple[int, str], int] = {}
  90. # Track starting energy for prints: {archive_id: starting_kwh}
  91. _print_energy_start: dict[int, float] = {}
  92. def register_expected_print(printer_id: int, filename: str, archive_id: int):
  93. """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
  94. # Store with multiple filename variations to catch different naming patterns
  95. _expected_prints[(printer_id, filename)] = archive_id
  96. # Also store without .3mf extension if present
  97. if filename.endswith(".3mf"):
  98. base = filename[:-4]
  99. _expected_prints[(printer_id, base)] = archive_id
  100. _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
  101. logging.getLogger(__name__).info(
  102. f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}"
  103. )
  104. _last_status_broadcast: dict[int, str] = {}
  105. _nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
  106. async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
  107. """Report filament usage to Spoolman after print completion.
  108. This finds the spool by RFID tag_uid from current AMS state and reports
  109. the filament_used_grams from the archive metadata.
  110. """
  111. async with async_session() as db:
  112. from backend.app.api.routes.settings import get_setting
  113. from backend.app.models.archive import PrintArchive
  114. # Check if Spoolman is enabled
  115. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  116. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  117. return
  118. # Get Spoolman URL
  119. spoolman_url = await get_setting(db, "spoolman_url")
  120. if not spoolman_url:
  121. return
  122. # Get or create Spoolman client
  123. client = await get_spoolman_client()
  124. if not client:
  125. client = await init_spoolman_client(spoolman_url)
  126. # Check if Spoolman is reachable
  127. if not await client.health_check():
  128. logger.warning("Spoolman not reachable for usage reporting")
  129. return
  130. # Get archive to find filament usage
  131. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  132. archive = result.scalar_one_or_none()
  133. if not archive or not archive.filament_used_grams:
  134. logger.debug(f"No filament usage data for archive {archive_id}")
  135. return
  136. filament_used = archive.filament_used_grams
  137. logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
  138. # Get current AMS state from printer to find the active spool
  139. state = printer_manager.get_status(printer_id)
  140. if not state or not state.raw_data:
  141. logger.debug("No printer state available for usage reporting")
  142. return
  143. ams_data = state.raw_data.get("ams")
  144. if not ams_data:
  145. logger.debug("No AMS data available for usage reporting")
  146. return
  147. # Find spools with RFID tags in Spoolman and report usage
  148. # For now, we report usage to the first spool found with a matching tag
  149. # TODO: In future, track which specific trays were used during the print
  150. spools_updated = 0
  151. for ams_unit in ams_data:
  152. trays = ams_unit.get("tray", [])
  153. for tray_data in trays:
  154. tag_uid = tray_data.get("tag_uid")
  155. if not tag_uid:
  156. continue
  157. # Find spool in Spoolman by tag
  158. spool = await client.find_spool_by_tag(tag_uid)
  159. if spool:
  160. # Report usage to Spoolman
  161. result = await client.use_spool(spool["id"], filament_used)
  162. if result:
  163. logger.info(
  164. f"[SPOOLMAN] Reported {filament_used}g usage to spool {spool['id']} (tag: {tag_uid})"
  165. )
  166. spools_updated += 1
  167. # Only report to one spool for single-material prints
  168. # Multi-material prints would need more sophisticated tracking
  169. return
  170. if spools_updated == 0:
  171. logger.debug(f"No matching Spoolman spools found for printer {printer_id}")
  172. async def on_printer_status_change(printer_id: int, state: PrinterState):
  173. """Handle printer status changes - broadcast via WebSocket."""
  174. # Only broadcast if something meaningful changed (reduce WebSocket spam)
  175. # Include rounded temperatures to detect meaningful temp changes (within 1 degree)
  176. temps = state.temperatures or {}
  177. nozzle_temp = round(temps.get("nozzle", 0))
  178. bed_temp = round(temps.get("bed", 0))
  179. nozzle_2_temp = round(temps.get("nozzle_2", 0)) if "nozzle_2" in temps else ""
  180. chamber_temp = round(temps.get("chamber", 0)) if "chamber" in temps else ""
  181. # Auto-detect dual-nozzle printers from MQTT temperature data
  182. if "nozzle_2" in temps and printer_id not in _nozzle_count_updated:
  183. _nozzle_count_updated.add(printer_id)
  184. # Update nozzle_count in database
  185. async with async_session() as db:
  186. from backend.app.models.printer import Printer
  187. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  188. printer = result.scalar_one_or_none()
  189. if printer and printer.nozzle_count != 2:
  190. printer.nozzle_count = 2
  191. await db.commit()
  192. logging.getLogger(__name__).info(
  193. f"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2"
  194. )
  195. # Include target temps for heating phase detection
  196. bed_target = round(temps.get("bed_target", 0))
  197. nozzle_target = round(temps.get("nozzle_target", 0))
  198. status_key = (
  199. f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
  200. f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
  201. f"{state.stg_cur}:{bed_target}:{nozzle_target}"
  202. )
  203. if _last_status_broadcast.get(printer_id) == status_key:
  204. return # No change, skip broadcast
  205. _last_status_broadcast[printer_id] = status_key
  206. await ws_manager.send_printer_status(
  207. printer_id,
  208. printer_state_to_dict(state, printer_id),
  209. )
  210. async def on_ams_change(printer_id: int, ams_data: list):
  211. """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
  212. import logging
  213. logger = logging.getLogger(__name__)
  214. try:
  215. async with async_session() as db:
  216. from backend.app.api.routes.settings import get_setting
  217. from backend.app.models.printer import Printer
  218. # Check if Spoolman is enabled
  219. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  220. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  221. return
  222. # Check sync mode
  223. sync_mode = await get_setting(db, "spoolman_sync_mode")
  224. if sync_mode and sync_mode != "auto":
  225. return # Only sync on auto mode
  226. # Get Spoolman URL
  227. spoolman_url = await get_setting(db, "spoolman_url")
  228. if not spoolman_url:
  229. return
  230. # Get or create Spoolman client
  231. client = await get_spoolman_client()
  232. if not client:
  233. client = await init_spoolman_client(spoolman_url)
  234. # Check if Spoolman is reachable
  235. if not await client.health_check():
  236. logger.warning(f"Spoolman not reachable at {spoolman_url}")
  237. return
  238. # Get printer name for location
  239. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  240. printer = result.scalar_one_or_none()
  241. printer_name = printer.name if printer else f"Printer {printer_id}"
  242. # Sync each AMS tray
  243. synced = 0
  244. for ams_unit in ams_data:
  245. ams_id = int(ams_unit.get("id", 0))
  246. trays = ams_unit.get("tray", [])
  247. for tray_data in trays:
  248. tray = client.parse_ams_tray(ams_id, tray_data)
  249. if not tray:
  250. continue # Empty tray
  251. try:
  252. result = await client.sync_ams_tray(tray, printer_name)
  253. if result:
  254. synced += 1
  255. except Exception as e:
  256. logger.error(f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}")
  257. if synced > 0:
  258. logger.info(f"Auto-synced {synced} AMS trays to Spoolman for printer {printer_id}")
  259. except Exception as e:
  260. import logging
  261. logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
  262. async def _send_print_start_notification(
  263. printer_id: int,
  264. data: dict,
  265. archive_data: dict | None = None,
  266. logger=None,
  267. ):
  268. """Helper to send print start notification with optional archive data."""
  269. if logger is None:
  270. import logging
  271. logger = logging.getLogger(__name__)
  272. try:
  273. async with async_session() as db:
  274. from backend.app.models.printer import Printer
  275. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  276. printer = result.scalar_one_or_none()
  277. printer_name = printer.name if printer else f"Printer {printer_id}"
  278. await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
  279. except Exception as e:
  280. logger.warning(f"Notification on_print_start failed: {e}")
  281. async def on_print_start(printer_id: int, data: dict):
  282. """Handle print start - archive the 3MF file immediately."""
  283. import logging
  284. logger = logging.getLogger(__name__)
  285. logger.info(f"[CALLBACK] on_print_start called for printer {printer_id}, data keys: {list(data.keys())}")
  286. await ws_manager.send_print_start(printer_id, data)
  287. # Track if notification was sent (to avoid sending twice)
  288. notification_sent = False
  289. # Smart plug automation: turn on plug when print starts
  290. try:
  291. async with async_session() as db:
  292. await smart_plug_manager.on_print_start(printer_id, db)
  293. except Exception as e:
  294. logger.warning(f"Smart plug on_print_start failed: {e}")
  295. async with async_session() as db:
  296. from backend.app.models.printer import Printer
  297. from backend.app.services.bambu_ftp import list_files_async
  298. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  299. printer = result.scalar_one_or_none()
  300. if not printer or not printer.auto_archive:
  301. # Send notification without archive data (auto-archive disabled)
  302. logger.info(
  303. f"[CALLBACK] Skipping archive - printer: {printer is not None}, auto_archive: {printer.auto_archive if printer else 'N/A'}"
  304. )
  305. if not notification_sent:
  306. await _send_print_start_notification(printer_id, data, logger=logger)
  307. return
  308. # Get the filename and subtask_name
  309. filename = data.get("filename", "")
  310. subtask_name = data.get("subtask_name", "")
  311. logger.info(f"[CALLBACK] Print start detected - filename: {filename}, subtask: {subtask_name}")
  312. if not filename and not subtask_name:
  313. # Send notification without archive data (no filename)
  314. logger.info("[CALLBACK] Skipping archive - no filename or subtask_name")
  315. if not notification_sent:
  316. await _send_print_start_notification(printer_id, data, logger=logger)
  317. return
  318. # Check if this is an expected print from reprint/scheduled
  319. # Build list of possible keys to check
  320. expected_keys = []
  321. if subtask_name:
  322. expected_keys.append((printer_id, subtask_name))
  323. expected_keys.append((printer_id, f"{subtask_name}.3mf"))
  324. expected_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
  325. if filename:
  326. fname = filename.split("/")[-1] if "/" in filename else filename
  327. expected_keys.append((printer_id, fname))
  328. # Strip extensions to match
  329. base = fname.replace(".gcode", "").replace(".3mf", "")
  330. expected_keys.append((printer_id, base))
  331. expected_keys.append((printer_id, f"{base}.3mf"))
  332. expected_archive_id = None
  333. for key in expected_keys:
  334. expected_archive_id = _expected_prints.pop(key, None)
  335. if expected_archive_id:
  336. # Clean up other possible keys for this print
  337. for other_key in expected_keys:
  338. _expected_prints.pop(other_key, None)
  339. break
  340. if expected_archive_id:
  341. # This is a reprint/scheduled print - use existing archive, don't create new one
  342. logger.info(f"Using expected archive {expected_archive_id} for print (skipping duplicate)")
  343. from datetime import datetime
  344. from backend.app.models.archive import PrintArchive
  345. result = await db.execute(select(PrintArchive).where(PrintArchive.id == expected_archive_id))
  346. archive = result.scalar_one_or_none()
  347. if archive:
  348. # Update archive status to printing
  349. archive.status = "printing"
  350. archive.started_at = datetime.now()
  351. await db.commit()
  352. # Track as active print
  353. _active_prints[(printer_id, archive.filename)] = archive.id
  354. if subtask_name:
  355. _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
  356. # Set up energy tracking
  357. try:
  358. plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  359. plug = plug_result.scalar_one_or_none()
  360. logger.info(
  361. f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
  362. )
  363. if plug:
  364. energy = await tasmota_service.get_energy(plug)
  365. logger.info(f"[ENERGY] Energy response from plug: {energy}")
  366. if energy and energy.get("total") is not None:
  367. _print_energy_start[archive.id] = energy["total"]
  368. logger.info(
  369. f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh"
  370. )
  371. else:
  372. logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
  373. else:
  374. logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
  375. except Exception as e:
  376. logger.warning(f"Failed to record starting energy: {e}")
  377. await ws_manager.send_archive_updated(
  378. {
  379. "id": archive.id,
  380. "status": "printing",
  381. }
  382. )
  383. # Send notification with archive data (reprint/scheduled)
  384. if not notification_sent:
  385. archive_data = {"print_time_seconds": archive.print_time_seconds}
  386. await _send_print_start_notification(printer_id, data, archive_data, logger)
  387. return # Skip creating a new archive
  388. # Check if there's already a "printing" archive for this printer/file
  389. # This prevents duplicates when backend restarts during an active print
  390. from backend.app.models.archive import PrintArchive
  391. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  392. existing = await db.execute(
  393. select(PrintArchive)
  394. .where(PrintArchive.printer_id == printer_id)
  395. .where(PrintArchive.status == "printing")
  396. .where(PrintArchive.print_name.ilike(f"%{check_name}%"))
  397. .order_by(PrintArchive.created_at.desc())
  398. .limit(1)
  399. )
  400. existing_archive = existing.scalar_one_or_none()
  401. if existing_archive:
  402. logger.info(f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}")
  403. # Track this as the active print
  404. _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
  405. # Also set up energy tracking if not already tracked
  406. if existing_archive.id not in _print_energy_start:
  407. try:
  408. plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  409. plug = plug_result.scalar_one_or_none()
  410. if plug:
  411. energy = await tasmota_service.get_energy(plug)
  412. if energy and energy.get("total") is not None:
  413. _print_energy_start[existing_archive.id] = energy["total"]
  414. logger.info(
  415. f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh"
  416. )
  417. except Exception as e:
  418. logger.warning(f"Failed to record starting energy for existing archive: {e}")
  419. # Send notification with archive data (existing archive)
  420. if not notification_sent:
  421. archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
  422. await _send_print_start_notification(printer_id, data, archive_data, logger)
  423. return
  424. # Build list of possible 3MF filenames to try
  425. possible_names = []
  426. # Bambu printers typically store files as "Name.gcode.3mf"
  427. # The subtask_name is usually the best source for the filename
  428. if subtask_name:
  429. # Try common Bambu naming patterns
  430. possible_names.append(f"{subtask_name}.gcode.3mf")
  431. possible_names.append(f"{subtask_name}.3mf")
  432. # Try original filename with .3mf extension
  433. if filename:
  434. # Extract just the filename part, not the full path
  435. fname = filename.split("/")[-1] if "/" in filename else filename
  436. if fname.endswith(".3mf"):
  437. possible_names.append(fname)
  438. elif fname.endswith(".gcode"):
  439. base = fname.rsplit(".", 1)[0]
  440. possible_names.append(f"{base}.gcode.3mf")
  441. possible_names.append(f"{base}.3mf")
  442. else:
  443. possible_names.append(f"{fname}.gcode.3mf")
  444. possible_names.append(f"{fname}.3mf")
  445. # Remove duplicates while preserving order
  446. seen = set()
  447. possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
  448. logger.info(f"Trying filenames: {possible_names}")
  449. # Try to find and download the 3MF file
  450. temp_path = None
  451. downloaded_filename = None
  452. for try_filename in possible_names:
  453. if not try_filename.endswith(".3mf"):
  454. continue
  455. remote_paths = [
  456. f"/cache/{try_filename}",
  457. f"/model/{try_filename}",
  458. f"/{try_filename}",
  459. ]
  460. temp_path = app_settings.archive_dir / "temp" / try_filename
  461. temp_path.parent.mkdir(parents=True, exist_ok=True)
  462. for remote_path in remote_paths:
  463. logger.debug(f"Trying FTP download: {remote_path}")
  464. try:
  465. if await download_file_async(
  466. printer.ip_address,
  467. printer.access_code,
  468. remote_path,
  469. temp_path,
  470. ):
  471. downloaded_filename = try_filename
  472. logger.info(f"Downloaded: {remote_path}")
  473. break
  474. except Exception as e:
  475. logger.debug(f"FTP download failed for {remote_path}: {e}")
  476. if downloaded_filename:
  477. break
  478. # If still not found, try listing /cache to find matching file
  479. if not downloaded_filename and (filename or subtask_name):
  480. search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
  481. try:
  482. cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
  483. for f in cache_files:
  484. if f.get("is_directory"):
  485. continue
  486. fname = f.get("name", "")
  487. if fname.endswith(".3mf") and search_term in fname.lower():
  488. temp_path = app_settings.archive_dir / "temp" / fname
  489. temp_path.parent.mkdir(parents=True, exist_ok=True)
  490. if await download_file_async(
  491. printer.ip_address,
  492. printer.access_code,
  493. f"/cache/{fname}",
  494. temp_path,
  495. ):
  496. downloaded_filename = fname
  497. logger.info(f"Found and downloaded from cache: {fname}")
  498. break
  499. except Exception as e:
  500. logger.warning(f"Failed to list cache: {e}")
  501. if not downloaded_filename or not temp_path:
  502. logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
  503. # Send notification without archive data (file not found)
  504. if not notification_sent:
  505. await _send_print_start_notification(printer_id, data, logger=logger)
  506. return
  507. try:
  508. # Archive the file with status "printing"
  509. service = ArchiveService(db)
  510. archive = await service.archive_print(
  511. printer_id=printer_id,
  512. source_file=temp_path,
  513. print_data={**data, "status": "printing"},
  514. )
  515. if archive:
  516. # Track this active print (use both original filename and downloaded filename)
  517. _active_prints[(printer_id, downloaded_filename)] = archive.id
  518. if filename and filename != downloaded_filename:
  519. _active_prints[(printer_id, filename)] = archive.id
  520. if subtask_name:
  521. _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
  522. logger.info(f"Created archive {archive.id} for {downloaded_filename}")
  523. # Record starting energy from smart plug if available
  524. try:
  525. plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  526. plug = plug_result.scalar_one_or_none()
  527. logger.info(
  528. f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
  529. )
  530. if plug:
  531. energy = await tasmota_service.get_energy(plug)
  532. logger.info(f"[ENERGY] Auto-archive energy response: {energy}")
  533. if energy and energy.get("total") is not None:
  534. _print_energy_start[archive.id] = energy["total"]
  535. logger.info(
  536. f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh"
  537. )
  538. else:
  539. logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
  540. else:
  541. logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
  542. except Exception as e:
  543. logger.warning(f"Failed to record starting energy: {e}")
  544. await ws_manager.send_archive_created(
  545. {
  546. "id": archive.id,
  547. "printer_id": archive.printer_id,
  548. "filename": archive.filename,
  549. "print_name": archive.print_name,
  550. "status": archive.status,
  551. }
  552. )
  553. # Send notification with archive data (new archive created)
  554. if not notification_sent:
  555. archive_data = {"print_time_seconds": archive.print_time_seconds}
  556. await _send_print_start_notification(printer_id, data, archive_data, logger)
  557. notification_sent = True
  558. finally:
  559. if temp_path and temp_path.exists():
  560. temp_path.unlink()
  561. async def _scan_for_timelapse_with_retries(archive_id: int):
  562. """
  563. Scan for timelapse with retries.
  564. The printer encodes the timelapse quickly after print completion.
  565. We just need a short delay then grab the most recent file.
  566. Since we KNOW timelapse was active (from MQTT ipcam data), the most recent
  567. file in /timelapse is our target. Retries handle FTP connection issues.
  568. """
  569. import logging
  570. logger = logging.getLogger(__name__)
  571. # Short delays - printer usually finishes encoding within seconds
  572. retry_delays = [5, 10, 20]
  573. for attempt, delay in enumerate(retry_delays, 1):
  574. logger.info(
  575. f"[TIMELAPSE] Attempt {attempt}/{len(retry_delays)}: waiting {delay}s before scanning for archive {archive_id}"
  576. )
  577. await asyncio.sleep(delay)
  578. try:
  579. async with async_session() as db:
  580. from backend.app.models.printer import Printer
  581. from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
  582. # Get archive (ArchiveService from module-level import)
  583. service = ArchiveService(db)
  584. archive = await service.get_archive(archive_id)
  585. if not archive:
  586. logger.warning(f"[TIMELAPSE] Archive {archive_id} not found, stopping retries")
  587. return
  588. if archive.timelapse_path:
  589. logger.info(f"[TIMELAPSE] Archive {archive_id} already has timelapse attached, stopping retries")
  590. return
  591. if not archive.printer_id:
  592. logger.warning(f"[TIMELAPSE] Archive {archive_id} has no printer, stopping retries")
  593. return
  594. # Get printer
  595. result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
  596. printer = result.scalar_one_or_none()
  597. if not printer:
  598. logger.warning(f"[TIMELAPSE] Printer not found for archive {archive_id}, stopping retries")
  599. return
  600. # Scan timelapse directory on printer
  601. # H2D may store in different locations than X1C
  602. files = []
  603. found_path = None
  604. for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
  605. try:
  606. found_files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
  607. if found_files:
  608. files = found_files
  609. found_path = timelapse_path
  610. logger.info(f"[TIMELAPSE] Attempt {attempt}: Found {len(files)} files in {timelapse_path}")
  611. break
  612. except Exception as e:
  613. logger.debug(f"[TIMELAPSE] Path {timelapse_path} failed: {e}")
  614. continue
  615. if not files:
  616. logger.info(f"[TIMELAPSE] Attempt {attempt}: No timelapse files found on printer, will retry")
  617. continue
  618. mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
  619. # Log ALL mp4 files found for debugging
  620. logger.info(f"[TIMELAPSE] Attempt {attempt}: Found {len(mp4_files)} MP4 files in {found_path}")
  621. for f in mp4_files[:5]: # Log first 5
  622. logger.info(f"[TIMELAPSE] - {f.get('name')}, mtime={f.get('mtime')}")
  623. if not mp4_files:
  624. logger.info(f"[TIMELAPSE] Attempt {attempt}: No MP4 files found, will retry")
  625. continue
  626. # Sort by mtime descending to get most recent file
  627. mp4_files_with_mtime = [f for f in mp4_files if f.get("mtime")]
  628. if not mp4_files_with_mtime:
  629. logger.info(f"[TIMELAPSE] Attempt {attempt}: No MP4 files with mtime found, will retry")
  630. continue
  631. mp4_files_with_mtime.sort(key=lambda x: x.get("mtime"), reverse=True)
  632. most_recent = mp4_files_with_mtime[0]
  633. file_name = most_recent.get("name")
  634. logger.info(f"[TIMELAPSE] Attempt {attempt}: Most recent file: {file_name}")
  635. # Since we KNOW timelapse was active (from MQTT), just grab the most recent file
  636. remote_path = most_recent.get("path") or f"/timelapse/{file_name}"
  637. logger.info(f"[TIMELAPSE] Downloading {file_name} for archive {archive_id}")
  638. timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
  639. if timelapse_data:
  640. success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
  641. if success:
  642. logger.info(f"[TIMELAPSE] Successfully attached timelapse to archive {archive_id}")
  643. await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
  644. return # Success!
  645. else:
  646. logger.warning(f"[TIMELAPSE] Failed to attach timelapse to archive {archive_id}")
  647. else:
  648. logger.warning(f"[TIMELAPSE] Attempt {attempt}: Failed to download, will retry")
  649. except Exception as e:
  650. logger.warning(f"[TIMELAPSE] Attempt {attempt} failed with error: {e}")
  651. logger.warning(f"[TIMELAPSE] All {len(retry_delays)} attempts exhausted for archive {archive_id}, giving up")
  652. async def on_print_complete(printer_id: int, data: dict):
  653. """Handle print completion - update the archive status."""
  654. import logging
  655. import time
  656. logger = logging.getLogger(__name__)
  657. start_time = time.time()
  658. def log_timing(section: str):
  659. elapsed = time.time() - start_time
  660. logger.info(f"[TIMING] {section}: {elapsed:.3f}s elapsed")
  661. logger.info(f"[CALLBACK] on_print_complete started for printer {printer_id}")
  662. try:
  663. ws_data = {
  664. "status": data.get("status"),
  665. "filename": data.get("filename"),
  666. "subtask_name": data.get("subtask_name"),
  667. "timelapse_was_active": data.get("timelapse_was_active"),
  668. }
  669. await ws_manager.send_print_complete(printer_id, ws_data)
  670. log_timing("WebSocket send_print_complete")
  671. except Exception as e:
  672. logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
  673. filename = data.get("filename", "")
  674. subtask_name = data.get("subtask_name", "")
  675. if not filename and not subtask_name:
  676. logger.warning("Print complete without filename or subtask_name")
  677. return
  678. logger.info(f"Print complete - filename: {filename}, subtask: {subtask_name}, status: {data.get('status')}")
  679. # Build list of possible keys to try (matching how they were registered in on_print_start)
  680. possible_keys = []
  681. # Try subtask_name variations first (most reliable for matching)
  682. if subtask_name:
  683. possible_keys.append((printer_id, f"{subtask_name}.3mf"))
  684. possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
  685. possible_keys.append((printer_id, subtask_name))
  686. # Try filename variations
  687. if filename:
  688. # Extract just the filename if it's a path
  689. fname = filename.split("/")[-1] if "/" in filename else filename
  690. if fname.endswith(".3mf"):
  691. possible_keys.append((printer_id, fname))
  692. elif fname.endswith(".gcode"):
  693. base_name = fname.rsplit(".", 1)[0]
  694. possible_keys.append((printer_id, f"{base_name}.gcode.3mf"))
  695. possible_keys.append((printer_id, f"{base_name}.3mf"))
  696. possible_keys.append((printer_id, fname))
  697. else:
  698. possible_keys.append((printer_id, f"{fname}.gcode.3mf"))
  699. possible_keys.append((printer_id, f"{fname}.3mf"))
  700. possible_keys.append((printer_id, fname))
  701. # Also try full path versions
  702. if filename.endswith(".3mf"):
  703. possible_keys.append((printer_id, filename))
  704. elif filename.endswith(".gcode"):
  705. base_name = filename.rsplit(".", 1)[0]
  706. possible_keys.append((printer_id, f"{base_name}.3mf"))
  707. possible_keys.append((printer_id, filename))
  708. else:
  709. possible_keys.append((printer_id, f"{filename}.3mf"))
  710. possible_keys.append((printer_id, filename))
  711. # Find the archive for this print
  712. logger.info(f"Looking for archive in _active_prints, keys to try: {possible_keys[:5]}...")
  713. logger.info(f"Current _active_prints: {list(_active_prints.keys())}")
  714. archive_id = None
  715. for key in possible_keys:
  716. archive_id = _active_prints.pop(key, None)
  717. if archive_id:
  718. logger.info(f"Found archive {archive_id} with key {key}")
  719. # Also clean up any other keys pointing to this archive
  720. keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
  721. for k in keys_to_remove:
  722. _active_prints.pop(k, None)
  723. break
  724. if not archive_id:
  725. # Try to find by filename or subtask_name if not tracked (for prints started before app)
  726. async with async_session() as db:
  727. from backend.app.models.archive import PrintArchive
  728. # Try matching by subtask_name (stored as print_name) first
  729. if subtask_name:
  730. result = await db.execute(
  731. select(PrintArchive)
  732. .where(PrintArchive.printer_id == printer_id)
  733. .where(PrintArchive.status == "printing")
  734. .where(
  735. or_(
  736. PrintArchive.print_name.ilike(f"%{subtask_name}%"),
  737. PrintArchive.filename.ilike(f"%{subtask_name}%"),
  738. )
  739. )
  740. .order_by(PrintArchive.created_at.desc())
  741. .limit(1)
  742. )
  743. archive = result.scalar_one_or_none()
  744. if archive:
  745. archive_id = archive.id
  746. logger.info(f"Found archive {archive_id} by subtask_name match: {subtask_name}")
  747. # Also try by filename
  748. if not archive_id and filename:
  749. result = await db.execute(
  750. select(PrintArchive)
  751. .where(PrintArchive.printer_id == printer_id)
  752. .where(PrintArchive.filename == filename)
  753. .where(PrintArchive.status == "printing")
  754. .order_by(PrintArchive.created_at.desc())
  755. .limit(1)
  756. )
  757. archive = result.scalar_one_or_none()
  758. if archive:
  759. archive_id = archive.id
  760. if not archive_id:
  761. logger.warning(f"Could not find archive for print complete: filename={filename}, subtask={subtask_name}")
  762. return
  763. log_timing("Archive lookup")
  764. # Update archive status
  765. logger.info(f"[ARCHIVE] Updating archive {archive_id} status...")
  766. try:
  767. async with async_session() as db:
  768. service = ArchiveService(db)
  769. status = data.get("status", "completed")
  770. # Auto-detect failure reason
  771. failure_reason = None
  772. if status == "aborted":
  773. failure_reason = "User cancelled"
  774. logger.info("[ARCHIVE] Print was aborted by user, setting failure_reason='User cancelled'")
  775. elif status == "failed":
  776. # Try to determine failure reason from HMS errors
  777. hms_errors = data.get("hms_errors", [])
  778. if hms_errors:
  779. logger.info(f"[ARCHIVE] HMS errors at failure: {hms_errors}")
  780. # Map known HMS error modules to failure reasons
  781. # Module 0x07 = Filament, 0x0C = MC (Motion Controller), etc.
  782. for err in hms_errors:
  783. module = err.get("module", 0)
  784. if module == 0x07: # Filament module
  785. failure_reason = "Filament runout"
  786. break
  787. elif module == 0x0C: # Motion controller
  788. failure_reason = "Layer shift"
  789. break
  790. elif module == 0x05: # Nozzle/extruder
  791. failure_reason = "Clogged nozzle"
  792. break
  793. if failure_reason:
  794. logger.info(f"[ARCHIVE] Detected failure_reason from HMS: {failure_reason}")
  795. else:
  796. logger.info("[ARCHIVE] No HMS errors available to determine failure reason")
  797. await service.update_archive_status(
  798. archive_id,
  799. status=status,
  800. completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None,
  801. failure_reason=failure_reason,
  802. )
  803. logger.info(f"[ARCHIVE] Archive {archive_id} status updated to {status}, failure_reason={failure_reason}")
  804. await ws_manager.send_archive_updated(
  805. {
  806. "id": archive_id,
  807. "status": status,
  808. }
  809. )
  810. logger.info(f"[ARCHIVE] WebSocket notification sent for archive {archive_id}")
  811. except Exception as e:
  812. logger.error(f"[ARCHIVE] Failed to update archive {archive_id} status: {e}", exc_info=True)
  813. # Continue with other operations even if archive update fails
  814. log_timing("Archive status update")
  815. # Report filament usage to Spoolman if print completed successfully
  816. if data.get("status") == "completed":
  817. try:
  818. await _report_spoolman_usage(printer_id, archive_id, logger)
  819. log_timing("Spoolman usage report")
  820. except Exception as e:
  821. logger.warning(f"Spoolman usage reporting failed: {e}")
  822. # Run slow operations as background tasks to avoid blocking the event loop
  823. # These operations can take 5-10+ seconds and would freeze the UI if awaited
  824. starting_kwh = _print_energy_start.pop(archive_id, None)
  825. async def _background_energy_calculation():
  826. """Calculate and save energy usage in background."""
  827. try:
  828. logger.info(f"[ENERGY-BG] Starting energy calculation for archive {archive_id}")
  829. async with async_session() as db:
  830. plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  831. plug = plug_result.scalar_one_or_none()
  832. if plug:
  833. energy = await tasmota_service.get_energy(plug)
  834. logger.info(f"[ENERGY-BG] Energy response: {energy}")
  835. energy_used = None
  836. if starting_kwh is not None and energy and energy.get("total") is not None:
  837. ending_kwh = energy["total"]
  838. energy_used = round(ending_kwh - starting_kwh, 4)
  839. logger.info(f"[ENERGY-BG] Per-print energy: {energy_used} kWh")
  840. if energy_used is not None and energy_used >= 0:
  841. from backend.app.api.routes.settings import get_setting
  842. energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
  843. cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
  844. energy_cost = round(energy_used * cost_per_kwh, 2)
  845. from backend.app.models.archive import PrintArchive
  846. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  847. archive = result.scalar_one_or_none()
  848. if archive:
  849. archive.energy_kwh = energy_used
  850. archive.energy_cost = energy_cost
  851. await db.commit()
  852. logger.info(f"[ENERGY-BG] Saved: {energy_used} kWh, cost={energy_cost}")
  853. else:
  854. logger.info(f"[ENERGY-BG] No smart plug for printer {printer_id}")
  855. except Exception as e:
  856. logger.warning(f"[ENERGY-BG] Failed: {e}")
  857. async def _background_finish_photo():
  858. """Capture finish photo in background."""
  859. try:
  860. logger.info(f"[PHOTO-BG] Starting finish photo capture for archive {archive_id}")
  861. from backend.app.api.routes.camera import _active_streams, get_buffered_frame
  862. async with async_session() as db:
  863. from backend.app.api.routes.settings import get_setting
  864. capture_enabled = await get_setting(db, "capture_finish_photo")
  865. if capture_enabled is None or capture_enabled.lower() == "true":
  866. from backend.app.models.printer import Printer
  867. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  868. printer = result.scalar_one_or_none()
  869. if printer and archive_id:
  870. from backend.app.models.archive import PrintArchive
  871. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  872. archive = result.scalar_one_or_none()
  873. if archive:
  874. import uuid
  875. from datetime import datetime
  876. from pathlib import Path
  877. archive_dir = app_settings.base_dir / Path(archive.file_path).parent
  878. photo_filename = None
  879. # Check if camera stream is active - use buffered frame to avoid freeze
  880. active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
  881. buffered_frame = get_buffered_frame(printer_id)
  882. if active_for_printer and buffered_frame:
  883. # Use frame from active stream
  884. logger.info("[PHOTO-BG] Using buffered frame from active stream")
  885. photos_dir = archive_dir / "photos"
  886. photos_dir.mkdir(parents=True, exist_ok=True)
  887. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  888. photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  889. photo_path = photos_dir / photo_filename
  890. await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
  891. logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}")
  892. else:
  893. # No active stream - capture new frame
  894. from backend.app.services.camera import capture_finish_photo
  895. photo_filename = await capture_finish_photo(
  896. printer_id=printer_id,
  897. ip_address=printer.ip_address,
  898. access_code=printer.access_code,
  899. model=printer.model,
  900. archive_dir=archive_dir,
  901. )
  902. if photo_filename:
  903. photos = archive.photos or []
  904. photos.append(photo_filename)
  905. archive.photos = photos
  906. await db.commit()
  907. logger.info(f"[PHOTO-BG] Saved: {photo_filename}")
  908. except Exception as e:
  909. logger.warning(f"[PHOTO-BG] Failed: {e}")
  910. asyncio.create_task(_background_energy_calculation())
  911. asyncio.create_task(_background_finish_photo()) # Skips if camera stream active
  912. log_timing("Background tasks scheduled (energy, photo)")
  913. # Also run smart plug, notifications, and maintenance as background tasks
  914. print_status = data.get("status", "completed")
  915. async def _background_smart_plug():
  916. """Handle smart plug automation in background."""
  917. try:
  918. logger.info(f"[AUTO-OFF-BG] Starting smart plug automation for printer {printer_id}")
  919. async with async_session() as db:
  920. await smart_plug_manager.on_print_complete(printer_id, print_status, db)
  921. logger.info("[AUTO-OFF-BG] Completed")
  922. except Exception as e:
  923. logger.warning(f"[AUTO-OFF-BG] Failed: {e}")
  924. async def _background_notifications():
  925. """Send print complete notifications in background."""
  926. try:
  927. logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}")
  928. async with async_session() as db:
  929. from backend.app.models.archive import PrintArchive
  930. from backend.app.models.printer import Printer
  931. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  932. printer = result.scalar_one_or_none()
  933. printer_name = printer.name if printer else f"Printer {printer_id}"
  934. archive_data = None
  935. if archive_id:
  936. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  937. archive = archive_result.scalar_one_or_none()
  938. if archive:
  939. archive_data = {
  940. "print_time_seconds": archive.print_time_seconds,
  941. "actual_filament_grams": archive.filament_used_grams,
  942. "failure_reason": archive.failure_reason,
  943. }
  944. await notification_service.on_print_complete(
  945. printer_id, printer_name, print_status, data, db, archive_data=archive_data
  946. )
  947. logger.info("[NOTIFY-BG] Completed")
  948. except Exception as e:
  949. logger.warning(f"[NOTIFY-BG] Failed: {e}")
  950. async def _background_maintenance_check():
  951. """Check for maintenance due in background."""
  952. if print_status != "completed":
  953. return
  954. try:
  955. logger.info(f"[MAINT-BG] Starting maintenance check for printer {printer_id}")
  956. async with async_session() as db:
  957. from backend.app.models.printer import Printer
  958. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  959. printer = result.scalar_one_or_none()
  960. printer_name = printer.name if printer else f"Printer {printer_id}"
  961. await ensure_default_types(db)
  962. overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
  963. items_needing_attention = [
  964. {"name": item.maintenance_type_name, "is_due": item.is_due, "is_warning": item.is_warning}
  965. for item in overview.maintenance_items
  966. if item.enabled and (item.is_due or item.is_warning)
  967. ]
  968. if items_needing_attention:
  969. await notification_service.on_maintenance_due(printer_id, printer_name, items_needing_attention, db)
  970. logger.info(f"[MAINT-BG] Sent notification: {len(items_needing_attention)} items need attention")
  971. else:
  972. logger.info("[MAINT-BG] Completed (no items need attention)")
  973. except Exception as e:
  974. logger.warning(f"[MAINT-BG] Failed: {e}")
  975. asyncio.create_task(_background_smart_plug())
  976. asyncio.create_task(_background_notifications())
  977. asyncio.create_task(_background_maintenance_check())
  978. log_timing("All background tasks scheduled")
  979. # Auto-scan for timelapse if recording was active during the print
  980. if archive_id and data.get("timelapse_was_active") and data.get("status") == "completed":
  981. logger.info(f"[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive {archive_id}")
  982. # Schedule timelapse scan as background task with retries
  983. # The printer needs time to encode the video after print completion
  984. asyncio.create_task(_scan_for_timelapse_with_retries(archive_id))
  985. log_timing("Timelapse scan scheduled")
  986. # Update queue item if this was a scheduled print
  987. try:
  988. async with async_session() as db:
  989. from backend.app.models.print_queue import PrintQueueItem
  990. # Note: SmartPlug is already imported at module level (line 56)
  991. # Do NOT import it here as it would shadow the module-level import
  992. # and cause "cannot access local variable" errors earlier in this function
  993. result = await db.execute(
  994. select(PrintQueueItem)
  995. .where(PrintQueueItem.printer_id == printer_id)
  996. .where(PrintQueueItem.status == "printing")
  997. )
  998. queue_item = result.scalar_one_or_none()
  999. if queue_item:
  1000. status = data.get("status", "completed")
  1001. queue_item.status = status
  1002. queue_item.completed_at = datetime.now()
  1003. await db.commit()
  1004. logger.info(f"Updated queue item {queue_item.id} status to {status}")
  1005. # Handle auto_off_after - power off printer if requested (after cooldown)
  1006. if queue_item.auto_off_after:
  1007. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  1008. plug = result.scalar_one_or_none()
  1009. if plug and plug.enabled:
  1010. logger.info(f"Auto-off requested for printer {printer_id}, waiting for cooldown...")
  1011. async def cooldown_and_poweroff(pid: int, plug_id: int):
  1012. # Wait for nozzle to cool down
  1013. await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
  1014. # Re-fetch plug in new session
  1015. async with async_session() as new_db:
  1016. result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
  1017. p = result.scalar_one_or_none()
  1018. if p and p.enabled:
  1019. success = await tasmota_service.turn_off(p)
  1020. if success:
  1021. logger.info(f"Powered off printer {pid} via smart plug '{p.name}'")
  1022. else:
  1023. logger.warning(f"Failed to power off printer {pid} via smart plug")
  1024. asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
  1025. except Exception as e:
  1026. import logging
  1027. logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
  1028. log_timing("Queue item update")
  1029. logger.info(f"[CALLBACK] on_print_complete finished for printer {printer_id}, archive {archive_id}")
  1030. # AMS sensor history recording
  1031. _ams_history_task: asyncio.Task | None = None
  1032. AMS_HISTORY_INTERVAL = 300 # Record every 5 minutes
  1033. AMS_HISTORY_RETENTION_DAYS = 30 # Keep data for 30 days
  1034. _ams_cleanup_counter = 0 # Track recordings to trigger periodic cleanup
  1035. _ams_alarm_cooldown: dict[str, datetime] = {} # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
  1036. AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
  1037. async def record_ams_history():
  1038. """Background task to record AMS humidity and temperature data."""
  1039. import logging
  1040. logger = logging.getLogger(__name__)
  1041. # Wait a short time for MQTT connections to establish on startup
  1042. await asyncio.sleep(10)
  1043. while True:
  1044. try:
  1045. from backend.app.models.ams_history import AMSSensorHistory
  1046. from backend.app.models.printer import Printer
  1047. from backend.app.models.settings import Settings
  1048. async with async_session() as db:
  1049. # Get all active printers
  1050. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  1051. printers = result.scalars().all()
  1052. # Get alarm thresholds from settings
  1053. humidity_threshold = 60.0 # Default: fair threshold
  1054. temp_threshold = 35.0 # Default: fair threshold
  1055. result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
  1056. setting = result.scalar_one_or_none()
  1057. if setting:
  1058. try:
  1059. humidity_threshold = float(setting.value)
  1060. except (ValueError, TypeError):
  1061. pass
  1062. result = await db.execute(select(Settings).where(Settings.key == "ams_temp_fair"))
  1063. setting = result.scalar_one_or_none()
  1064. if setting:
  1065. try:
  1066. temp_threshold = float(setting.value)
  1067. except (ValueError, TypeError):
  1068. pass
  1069. recorded_count = 0
  1070. for printer in printers:
  1071. # Get current state from printer manager
  1072. state = printer_manager.get_status(printer.id)
  1073. if not state or not state.raw_data:
  1074. continue
  1075. raw_data = state.raw_data
  1076. if "ams" not in raw_data or not isinstance(raw_data["ams"], list):
  1077. continue
  1078. # Record data for each AMS unit
  1079. for ams_data in raw_data["ams"]:
  1080. ams_id = int(ams_data.get("id", 0))
  1081. # Get humidity (prefer humidity_raw)
  1082. humidity_raw = ams_data.get("humidity_raw")
  1083. humidity_idx = ams_data.get("humidity")
  1084. humidity = None
  1085. if humidity_raw is not None:
  1086. try:
  1087. humidity = float(humidity_raw)
  1088. except (ValueError, TypeError):
  1089. pass
  1090. if humidity is None and humidity_idx is not None:
  1091. try:
  1092. humidity = float(humidity_idx)
  1093. except (ValueError, TypeError):
  1094. pass
  1095. # Get temperature
  1096. temperature = None
  1097. temp_str = ams_data.get("temp")
  1098. if temp_str is not None:
  1099. try:
  1100. temperature = float(temp_str)
  1101. except (ValueError, TypeError):
  1102. pass
  1103. # Skip if no data
  1104. if humidity is None and temperature is None:
  1105. continue
  1106. # Record the data point
  1107. history = AMSSensorHistory(
  1108. printer_id=printer.id,
  1109. ams_id=ams_id,
  1110. humidity=humidity,
  1111. humidity_raw=float(humidity_raw) if humidity_raw else None,
  1112. temperature=temperature,
  1113. )
  1114. db.add(history)
  1115. recorded_count += 1
  1116. # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)
  1117. is_ams_ht = ams_id >= 128
  1118. if is_ams_ht:
  1119. ams_label = f"HT-{chr(65 + (ams_id - 128))}"
  1120. else:
  1121. ams_label = f"AMS-{chr(65 + ams_id)}"
  1122. # Check humidity alarm (only if above threshold)
  1123. if humidity is not None and humidity > humidity_threshold:
  1124. cooldown_key = f"{printer.id}:{ams_id}:humidity"
  1125. last_alarm = _ams_alarm_cooldown.get(cooldown_key)
  1126. now = datetime.now()
  1127. if (
  1128. last_alarm is None
  1129. or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
  1130. ):
  1131. _ams_alarm_cooldown[cooldown_key] = now
  1132. logger.info(
  1133. f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%"
  1134. )
  1135. try:
  1136. # Call different notification method based on AMS type
  1137. if is_ams_ht:
  1138. await notification_service.on_ams_ht_humidity_high(
  1139. printer.id, printer.name, ams_label, humidity, humidity_threshold, db
  1140. )
  1141. else:
  1142. await notification_service.on_ams_humidity_high(
  1143. printer.id, printer.name, ams_label, humidity, humidity_threshold, db
  1144. )
  1145. except Exception as e:
  1146. logger.warning(f"Failed to send humidity alarm: {e}")
  1147. # Check temperature alarm (only if above threshold)
  1148. if temperature is not None and temperature > temp_threshold:
  1149. cooldown_key = f"{printer.id}:{ams_id}:temperature"
  1150. last_alarm = _ams_alarm_cooldown.get(cooldown_key)
  1151. now = datetime.now()
  1152. if (
  1153. last_alarm is None
  1154. or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
  1155. ):
  1156. _ams_alarm_cooldown[cooldown_key] = now
  1157. logger.info(
  1158. f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C"
  1159. )
  1160. try:
  1161. # Call different notification method based on AMS type
  1162. if is_ams_ht:
  1163. await notification_service.on_ams_ht_temperature_high(
  1164. printer.id, printer.name, ams_label, temperature, temp_threshold, db
  1165. )
  1166. else:
  1167. await notification_service.on_ams_temperature_high(
  1168. printer.id, printer.name, ams_label, temperature, temp_threshold, db
  1169. )
  1170. except Exception as e:
  1171. logger.warning(f"Failed to send temperature alarm: {e}")
  1172. await db.commit()
  1173. if recorded_count > 0:
  1174. logger.info(f"Recorded {recorded_count} AMS sensor history entries")
  1175. # Periodic cleanup of old data (every ~288 recordings = ~24 hours at 5min interval)
  1176. global _ams_cleanup_counter
  1177. _ams_cleanup_counter += 1
  1178. if _ams_cleanup_counter >= 288:
  1179. _ams_cleanup_counter = 0
  1180. # Get retention days from settings
  1181. from backend.app.models.settings import Settings
  1182. result = await db.execute(select(Settings).where(Settings.key == "ams_history_retention_days"))
  1183. setting = result.scalar_one_or_none()
  1184. retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
  1185. cutoff = datetime.now() - timedelta(days=retention_days)
  1186. result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))
  1187. await db.commit()
  1188. if result.rowcount > 0:
  1189. logger.info(
  1190. f"Cleaned up {result.rowcount} old AMS sensor history entries (older than {retention_days} days)"
  1191. )
  1192. # Wait until next recording interval
  1193. await asyncio.sleep(AMS_HISTORY_INTERVAL)
  1194. except asyncio.CancelledError:
  1195. break
  1196. except Exception as e:
  1197. logger.warning(f"AMS history recording failed: {e}")
  1198. await asyncio.sleep(60) # Wait a bit before retrying
  1199. def start_ams_history_recording():
  1200. """Start the AMS history recording background task."""
  1201. global _ams_history_task
  1202. if _ams_history_task is None:
  1203. _ams_history_task = asyncio.create_task(record_ams_history())
  1204. logging.getLogger(__name__).info("AMS history recording started")
  1205. def stop_ams_history_recording():
  1206. """Stop the AMS history recording background task."""
  1207. global _ams_history_task
  1208. if _ams_history_task:
  1209. _ams_history_task.cancel()
  1210. _ams_history_task = None
  1211. logging.getLogger(__name__).info("AMS history recording stopped")
  1212. @asynccontextmanager
  1213. async def lifespan(app: FastAPI):
  1214. # Startup
  1215. await init_db()
  1216. # Set up printer manager callbacks
  1217. loop = asyncio.get_event_loop()
  1218. printer_manager.set_event_loop(loop)
  1219. printer_manager.set_status_change_callback(on_printer_status_change)
  1220. printer_manager.set_print_start_callback(on_print_start)
  1221. printer_manager.set_print_complete_callback(on_print_complete)
  1222. printer_manager.set_ams_change_callback(on_ams_change)
  1223. # Connect to all active printers
  1224. async with async_session() as db:
  1225. await init_printer_connections(db)
  1226. # Auto-connect to Spoolman if enabled
  1227. async with async_session() as db:
  1228. from backend.app.api.routes.settings import get_setting
  1229. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  1230. spoolman_url = await get_setting(db, "spoolman_url")
  1231. if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
  1232. try:
  1233. client = await init_spoolman_client(spoolman_url)
  1234. if await client.health_check():
  1235. logging.info(f"Auto-connected to Spoolman at {spoolman_url}")
  1236. else:
  1237. logging.warning(f"Spoolman at {spoolman_url} is not reachable")
  1238. except Exception as e:
  1239. logging.warning(f"Failed to auto-connect to Spoolman: {e}")
  1240. # Start the print scheduler
  1241. asyncio.create_task(print_scheduler.run())
  1242. # Start the smart plug scheduler for time-based on/off
  1243. smart_plug_manager.start_scheduler()
  1244. # Resume any pending auto-offs that were interrupted by restart
  1245. await smart_plug_manager.resume_pending_auto_offs()
  1246. # Start the notification digest scheduler
  1247. notification_service.start_digest_scheduler()
  1248. # Start AMS history recording
  1249. start_ams_history_recording()
  1250. # Start anonymous telemetry (opt-out via settings)
  1251. asyncio.create_task(start_telemetry_loop(async_session))
  1252. yield
  1253. # Shutdown
  1254. print_scheduler.stop()
  1255. smart_plug_manager.stop_scheduler()
  1256. notification_service.stop_digest_scheduler()
  1257. stop_ams_history_recording()
  1258. printer_manager.disconnect_all()
  1259. await close_spoolman_client()
  1260. app = FastAPI(
  1261. title=app_settings.app_name,
  1262. description="Archive and manage Bambu Lab 3MF files",
  1263. version=APP_VERSION,
  1264. lifespan=lifespan,
  1265. )
  1266. # API routes
  1267. app.include_router(printers.router, prefix=app_settings.api_prefix)
  1268. app.include_router(archives.router, prefix=app_settings.api_prefix)
  1269. app.include_router(filaments.router, prefix=app_settings.api_prefix)
  1270. app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
  1271. app.include_router(cloud.router, prefix=app_settings.api_prefix)
  1272. app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
  1273. app.include_router(print_queue.router, prefix=app_settings.api_prefix)
  1274. app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
  1275. app.include_router(notifications.router, prefix=app_settings.api_prefix)
  1276. app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
  1277. app.include_router(spoolman.router, prefix=app_settings.api_prefix)
  1278. app.include_router(updates.router, prefix=app_settings.api_prefix)
  1279. app.include_router(maintenance.router, prefix=app_settings.api_prefix)
  1280. app.include_router(camera.router, prefix=app_settings.api_prefix)
  1281. app.include_router(external_links.router, prefix=app_settings.api_prefix)
  1282. app.include_router(projects.router, prefix=app_settings.api_prefix)
  1283. app.include_router(api_keys.router, prefix=app_settings.api_prefix)
  1284. app.include_router(webhook.router, prefix=app_settings.api_prefix)
  1285. app.include_router(ams_history.router, prefix=app_settings.api_prefix)
  1286. app.include_router(system.router, prefix=app_settings.api_prefix)
  1287. app.include_router(websocket.router, prefix=app_settings.api_prefix)
  1288. # Serve static files (React build)
  1289. if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
  1290. app.mount(
  1291. "/assets",
  1292. StaticFiles(directory=app_settings.static_dir / "assets"),
  1293. name="assets",
  1294. )
  1295. if (app_settings.static_dir / "img").exists():
  1296. app.mount(
  1297. "/img",
  1298. StaticFiles(directory=app_settings.static_dir / "img"),
  1299. name="img",
  1300. )
  1301. if (app_settings.static_dir / "icons").exists():
  1302. app.mount(
  1303. "/icons",
  1304. StaticFiles(directory=app_settings.static_dir / "icons"),
  1305. name="icons",
  1306. )
  1307. @app.get("/")
  1308. async def serve_frontend():
  1309. """Serve the React frontend."""
  1310. index_file = app_settings.static_dir / "index.html"
  1311. if index_file.exists():
  1312. return FileResponse(index_file)
  1313. return {
  1314. "message": "Bambuddy API",
  1315. "docs": "/docs",
  1316. "frontend": "Build and place React app in /static directory",
  1317. }
  1318. @app.get("/health")
  1319. async def health_check():
  1320. """Health check endpoint."""
  1321. return {"status": "healthy"}
  1322. @app.get("/manifest.json")
  1323. async def serve_manifest():
  1324. """Serve PWA manifest."""
  1325. manifest_file = app_settings.static_dir / "manifest.json"
  1326. if manifest_file.exists():
  1327. return FileResponse(manifest_file, media_type="application/manifest+json")
  1328. return {"error": "Manifest not found"}
  1329. @app.get("/sw.js")
  1330. async def serve_service_worker():
  1331. """Serve service worker."""
  1332. sw_file = app_settings.static_dir / "sw.js"
  1333. if sw_file.exists():
  1334. return FileResponse(sw_file, media_type="application/javascript")
  1335. return {"error": "Service worker not found"}
  1336. # Catch-all route for React Router (must be last)
  1337. @app.get("/{full_path:path}")
  1338. async def serve_spa(full_path: str):
  1339. """Serve React app for client-side routing."""
  1340. # Don't intercept API routes
  1341. if full_path.startswith("api/"):
  1342. return {"error": "Not found"}
  1343. index_file = app_settings.static_dir / "index.html"
  1344. if index_file.exists():
  1345. return FileResponse(index_file)
  1346. return {"error": "Frontend not built"}