orca_profiles.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. """Service for importing and resolving OrcaSlicer profiles.
  2. Handles:
  3. - Parsing .json, .orca_filament, .zip exports
  4. - Fetching base Bambu profiles from OrcaSlicer GitHub for inheritance resolution
  5. - Caching base profiles in the database with TTL
  6. - Extracting core fields for quick access
  7. """
  8. import io
  9. import json
  10. import logging
  11. import zipfile
  12. from datetime import datetime, timedelta, timezone
  13. import httpx
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.models.local_preset import LocalPreset
  17. from backend.app.models.orca_base_cache import OrcaBaseProfile
  18. logger = logging.getLogger(__name__)
  19. ORCA_BASE_URL = "https://raw.githubusercontent.com/SoftFever/OrcaSlicer/main/resources/profiles/BBL"
  20. CACHE_TTL_DAYS = 7
  21. MAX_INHERITANCE_DEPTH = 10
  22. async def get_cached_base_profile(name: str, db: AsyncSession) -> dict | None:
  23. """Get a base profile from cache if still fresh."""
  24. result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
  25. profile = result.scalar_one_or_none()
  26. if not profile:
  27. return None
  28. # Check TTL
  29. cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
  30. fetched = profile.fetched_at
  31. if fetched.tzinfo is None:
  32. fetched = fetched.replace(tzinfo=timezone.utc)
  33. if fetched < cutoff:
  34. return None
  35. try:
  36. return json.loads(profile.setting)
  37. except Exception:
  38. return None
  39. async def fetch_and_cache_base_profile(name: str, profile_type: str, db: AsyncSession) -> dict | None:
  40. """Fetch a base profile from OrcaSlicer GitHub and cache it."""
  41. # Check cache first
  42. cached = await get_cached_base_profile(name, db)
  43. if cached is not None:
  44. return cached
  45. # Map profile_type to GitHub subdirectory
  46. type_dirs = {
  47. "filament": "filament",
  48. "machine": "machine",
  49. "printer": "machine",
  50. "process": "process",
  51. }
  52. subdir = type_dirs.get(profile_type, "filament")
  53. # Try fetching from GitHub
  54. urls_to_try = [
  55. f"{ORCA_BASE_URL}/{subdir}/{name}.json",
  56. ]
  57. # Also try filament dir as fallback for any type
  58. if subdir != "filament":
  59. urls_to_try.append(f"{ORCA_BASE_URL}/filament/{name}.json")
  60. data = None
  61. async with httpx.AsyncClient(timeout=15.0) as client:
  62. for url in urls_to_try:
  63. try:
  64. resp = await client.get(url)
  65. if resp.status_code == 200:
  66. data = resp.json()
  67. break
  68. except Exception as e:
  69. logger.debug("Failed to fetch %s: %s", url, e)
  70. if data is None:
  71. logger.warning("Could not fetch base profile '%s' from GitHub", name)
  72. return None
  73. # Cache in DB
  74. setting_json = json.dumps(data)
  75. result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
  76. existing = result.scalar_one_or_none()
  77. if existing:
  78. existing.setting = setting_json
  79. existing.profile_type = profile_type
  80. existing.fetched_at = datetime.now(timezone.utc)
  81. else:
  82. cache_entry = OrcaBaseProfile(
  83. name=name,
  84. profile_type=profile_type,
  85. setting=setting_json,
  86. fetched_at=datetime.now(timezone.utc),
  87. )
  88. db.add(cache_entry)
  89. return data
  90. async def resolve_preset(preset_data: dict, profile_type: str, db: AsyncSession, depth: int = 0) -> dict:
  91. """Recursively resolve inheritance chain, merging parent into child.
  92. OrcaSlicer uses shallow merge: child keys fully replace parent keys.
  93. """
  94. if depth >= MAX_INHERITANCE_DEPTH:
  95. logger.warning("Inheritance depth limit reached for preset")
  96. return preset_data
  97. inherits = preset_data.get("inherits")
  98. if not inherits:
  99. return preset_data
  100. # Fetch the base profile
  101. base = await fetch_and_cache_base_profile(inherits, profile_type, db)
  102. if base is None:
  103. logger.warning("Cannot resolve inherits='%s' — base profile not found", inherits)
  104. return preset_data
  105. # Recursively resolve the base first
  106. resolved_base = await resolve_preset(base, profile_type, db, depth + 1)
  107. # Shallow merge: start with base, override with child
  108. merged = {**resolved_base, **preset_data}
  109. return merged
  110. def extract_core_fields(data: dict) -> dict:
  111. """Extract commonly needed fields from a resolved preset for quick access."""
  112. fields: dict = {}
  113. # filament_type — often a single-element array like ["PLA"]
  114. ft = data.get("filament_type")
  115. if isinstance(ft, list) and ft:
  116. fields["filament_type"] = str(ft[0])
  117. elif isinstance(ft, str):
  118. fields["filament_type"] = ft
  119. # filament_vendor
  120. fv = data.get("filament_vendor")
  121. if isinstance(fv, list) and fv:
  122. fields["filament_vendor"] = str(fv[0])
  123. elif isinstance(fv, str):
  124. fields["filament_vendor"] = fv
  125. # nozzle_temp_min / max — from nozzle_temperature array or range fields
  126. nozzle_temp = data.get("nozzle_temperature")
  127. if isinstance(nozzle_temp, list) and nozzle_temp:
  128. try:
  129. temps = [int(t) for t in nozzle_temp if str(t).isdigit()]
  130. if temps:
  131. fields["nozzle_temp_min"] = min(temps)
  132. fields["nozzle_temp_max"] = max(temps)
  133. except (ValueError, TypeError):
  134. pass
  135. # Override with explicit range fields if present
  136. range_low = data.get("nozzle_temperature_range_low")
  137. range_high = data.get("nozzle_temperature_range_high")
  138. if isinstance(range_low, list) and range_low:
  139. try:
  140. fields["nozzle_temp_min"] = int(range_low[0])
  141. except (ValueError, TypeError):
  142. pass
  143. if isinstance(range_high, list) and range_high:
  144. try:
  145. fields["nozzle_temp_max"] = int(range_high[0])
  146. except (ValueError, TypeError):
  147. pass
  148. # pressure_advance — store as JSON string if it's an array
  149. pa = data.get("pressure_advance")
  150. if pa is not None:
  151. fields["pressure_advance"] = json.dumps(pa) if isinstance(pa, list) else str(pa)
  152. # default_filament_colour
  153. colour = data.get("default_filament_colour")
  154. if colour is not None:
  155. fields["default_filament_colour"] = json.dumps(colour) if isinstance(colour, list) else str(colour)
  156. # filament_cost
  157. cost = data.get("filament_cost")
  158. if isinstance(cost, list) and cost:
  159. fields["filament_cost"] = str(cost[0])
  160. elif cost is not None:
  161. fields["filament_cost"] = str(cost)
  162. # filament_density
  163. density = data.get("filament_density")
  164. if isinstance(density, list) and density:
  165. fields["filament_density"] = str(density[0])
  166. elif density is not None:
  167. fields["filament_density"] = str(density)
  168. # compatible_printers
  169. compat = data.get("compatible_printers")
  170. if isinstance(compat, list):
  171. fields["compatible_printers"] = json.dumps(compat)
  172. return fields
  173. MATERIAL_TYPES = [
  174. "PLA",
  175. "ABS",
  176. "ASA",
  177. "PETG",
  178. "TPU",
  179. "PA",
  180. "PC",
  181. "PVA",
  182. "HIPS",
  183. "PET",
  184. "PP",
  185. "PEI",
  186. "PEEK",
  187. "PCTG",
  188. "PPA",
  189. "POM",
  190. ]
  191. def _parse_material_from_name(name: str) -> str | None:
  192. """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'."""
  193. import re
  194. upper = name.upper()
  195. for mat in MATERIAL_TYPES:
  196. if re.search(rf"\b{mat}\b", upper):
  197. return mat
  198. return None
  199. def _parse_vendor_from_name(name: str) -> str | None:
  200. """Extract vendor from preset name, e.g. 'Overture PLA Matte @BBL X1C' -> 'Overture'."""
  201. import re
  202. # Strip @printer suffix
  203. clean = re.sub(r"@.+$", "", name).strip()
  204. upper = clean.upper()
  205. for mat in MATERIAL_TYPES:
  206. idx = upper.find(mat)
  207. if idx > 0:
  208. vendor = clean[:idx].strip()
  209. if vendor and len(vendor) > 1:
  210. return vendor
  211. return None
  212. def _type_from_path(zip_entry: str) -> str | None:
  213. """Infer profile type from the ZIP directory path."""
  214. parts = zip_entry.lower().replace("\\", "/").split("/")
  215. for part in parts:
  216. if part in ("filament",):
  217. return "filament"
  218. if part in ("machine", "printer"):
  219. return "printer"
  220. if part in ("process", "print"):
  221. return "process"
  222. return None
  223. def _guess_profile_type(data: dict, path_hint: str | None = None) -> str:
  224. """Determine the profile type from JSON data and optional ZIP path hint."""
  225. import re
  226. # 1. Explicit "type" field set by OrcaSlicer
  227. explicit = data.get("type", "").lower()
  228. if explicit in ("filament",):
  229. return "filament"
  230. if explicit in ("machine", "printer"):
  231. return "printer"
  232. if explicit in ("process", "print"):
  233. return "process"
  234. # 2. ZIP directory path hint (e.g. "filament/MyPreset.json")
  235. if path_hint:
  236. from_path = _type_from_path(path_hint)
  237. if from_path:
  238. return from_path
  239. # 3. Strong ID-based heuristics — *_settings_id is definitive
  240. if "print_settings_id" in data:
  241. return "process"
  242. if "filament_settings_id" in data:
  243. return "filament"
  244. if "printer_settings_id" in data:
  245. return "printer"
  246. # 4. Content-based heuristics — check process BEFORE filament because
  247. # resolved process presets can inherit filament_type from their base
  248. process_keys = {
  249. "layer_height",
  250. "first_layer_height",
  251. "wall_loops",
  252. "prime_tower_width",
  253. "prime_tower_max_speed",
  254. "prime_tower_rib_wall",
  255. "outer_wall_speed",
  256. "inner_wall_speed",
  257. "interlocking_depth",
  258. "bottom_shell_layers",
  259. "top_shell_layers",
  260. "sparse_infill_density",
  261. }
  262. if process_keys & data.keys():
  263. return "process"
  264. if "machine_max_speed_x" in data or "printer_model" in data or "bed_shape" in data:
  265. return "printer"
  266. if "filament_type" in data or "filament_vendor" in data:
  267. return "filament"
  268. # 5. Name-based heuristics as last resort
  269. name = data.get("name", "")
  270. if re.search(r"\d+\.\d+mm\s", name):
  271. return "process"
  272. if name.lower().endswith("process"):
  273. return "process"
  274. return "filament"
  275. async def import_orca_file(filename: str, content: bytes, db: AsyncSession) -> dict:
  276. """Import presets from a file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip).
  277. Returns dict with keys: success, imported, skipped, errors.
  278. """
  279. imported = 0
  280. skipped = 0
  281. errors: list[str] = []
  282. # Determine file type
  283. lower_name = filename.lower()
  284. if lower_name.endswith(".json"):
  285. # Single JSON preset
  286. try:
  287. data = json.loads(content)
  288. result = await _import_single_preset(data, db, path_hint=filename)
  289. if result == "imported":
  290. imported += 1
  291. elif result == "skipped":
  292. skipped += 1
  293. else:
  294. errors.append(result)
  295. except json.JSONDecodeError as e:
  296. errors.append(f"Invalid JSON: {e}")
  297. elif lower_name.endswith((".orca_filament", ".zip", ".bbscfg", ".bbsflmt")):
  298. # ZIP archive — extract and parse each JSON
  299. try:
  300. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  301. for entry in zf.namelist():
  302. if entry.endswith(".json") and "bundle_structure" not in entry:
  303. try:
  304. raw = zf.read(entry)
  305. data = json.loads(raw)
  306. result = await _import_single_preset(data, db, path_hint=entry)
  307. if result == "imported":
  308. imported += 1
  309. elif result == "skipped":
  310. skipped += 1
  311. else:
  312. errors.append(f"{entry}: {result}")
  313. except json.JSONDecodeError:
  314. errors.append(f"{entry}: Invalid JSON")
  315. except Exception as e:
  316. errors.append(f"{entry}: {e}")
  317. except zipfile.BadZipFile:
  318. errors.append("Invalid ZIP/orca_filament archive")
  319. else:
  320. errors.append(f"Unsupported file type: {filename}")
  321. return {
  322. "success": imported > 0 or (imported == 0 and skipped > 0 and not errors),
  323. "imported": imported,
  324. "skipped": skipped,
  325. "errors": errors,
  326. }
  327. async def _import_single_preset(data: dict, db: AsyncSession, path_hint: str | None = None) -> str:
  328. """Import a single preset dict. Returns 'imported', 'skipped', or error string."""
  329. name = data.get("name")
  330. if not name:
  331. return "Preset has no name"
  332. # Check for duplicate by name
  333. result = await db.execute(select(LocalPreset).where(LocalPreset.name == name))
  334. if result.scalar_one_or_none():
  335. return "skipped"
  336. profile_type = _guess_profile_type(data, path_hint)
  337. inherits_value = data.get("inherits")
  338. # Resolve inheritance
  339. try:
  340. resolved = await resolve_preset(data, profile_type, db)
  341. except Exception as e:
  342. logger.warning("Failed to resolve inheritance for '%s': %s", name, e)
  343. resolved = data
  344. # Extract core fields
  345. core = extract_core_fields(resolved)
  346. # Fallback: parse material/vendor from preset name if not found in data
  347. filament_type = core.get("filament_type") or _parse_material_from_name(name)
  348. filament_vendor = core.get("filament_vendor") or _parse_vendor_from_name(name)
  349. preset = LocalPreset(
  350. name=name,
  351. preset_type=profile_type,
  352. source="orcaslicer",
  353. filament_type=filament_type,
  354. filament_vendor=filament_vendor,
  355. nozzle_temp_min=core.get("nozzle_temp_min"),
  356. nozzle_temp_max=core.get("nozzle_temp_max"),
  357. pressure_advance=core.get("pressure_advance"),
  358. default_filament_colour=core.get("default_filament_colour"),
  359. filament_cost=core.get("filament_cost"),
  360. filament_density=core.get("filament_density"),
  361. compatible_printers=core.get("compatible_printers"),
  362. setting=json.dumps(resolved),
  363. inherits=inherits_value,
  364. version=data.get("version"),
  365. )
  366. db.add(preset)
  367. return "imported"
  368. async def refresh_base_cache(db: AsyncSession) -> dict:
  369. """Force refresh all cached base profiles."""
  370. result = await db.execute(select(OrcaBaseProfile))
  371. profiles = result.scalars().all()
  372. refreshed = 0
  373. failed = 0
  374. for profile in profiles:
  375. # Clear fetched_at to force re-fetch
  376. try:
  377. profile.fetched_at = datetime.min
  378. data = await fetch_and_cache_base_profile(profile.name, profile.profile_type, db)
  379. if data:
  380. refreshed += 1
  381. else:
  382. failed += 1
  383. except Exception:
  384. failed += 1
  385. return {"refreshed": refreshed, "failed": failed, "total": len(profiles)}
  386. async def get_cache_status(db: AsyncSession) -> dict:
  387. """Get the status of the base profile cache."""
  388. result = await db.execute(select(OrcaBaseProfile))
  389. profiles = result.scalars().all()
  390. cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
  391. fresh = 0
  392. stale = 0
  393. for p in profiles:
  394. fetched = p.fetched_at
  395. if fetched.tzinfo is None:
  396. fetched = fetched.replace(tzinfo=timezone.utc)
  397. if fetched >= cutoff:
  398. fresh += 1
  399. else:
  400. stale += 1
  401. return {
  402. "total": len(profiles),
  403. "fresh": fresh,
  404. "stale": stale,
  405. "ttl_days": CACHE_TTL_DAYS,
  406. }
  407. async def reclassify_presets(db: AsyncSession) -> dict:
  408. """Re-evaluate preset_type for all local presets using the improved heuristic."""
  409. result = await db.execute(select(LocalPreset))
  410. presets = result.scalars().all()
  411. reclassified = 0
  412. for preset in presets:
  413. try:
  414. data = json.loads(preset.setting)
  415. except Exception:
  416. continue
  417. new_type = _guess_profile_type(data)
  418. if new_type != preset.preset_type:
  419. logger.info(
  420. "Reclassifying '%s' from '%s' to '%s'",
  421. preset.name,
  422. preset.preset_type,
  423. new_type,
  424. )
  425. preset.preset_type = new_type
  426. reclassified += 1
  427. return {"total": len(presets), "reclassified": reclassified}