main.py 67 KB

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